mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
mobile phase 1
This commit is contained in:
@@ -29,6 +29,7 @@ import UserSessionService, {
|
||||
SessionMetadata,
|
||||
} from "Common/Server/Services/UserSessionService";
|
||||
import CookieUtil from "Common/Server/Utils/Cookie";
|
||||
import JSONWebToken from "Common/Server/Utils/JsonWebToken";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
@@ -54,6 +55,11 @@ const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
const ACCESS_TOKEN_EXPIRY_SECONDS: number = 15 * 60;
|
||||
|
||||
interface FinalizeUserLoginResult {
|
||||
sessionMetadata: SessionMetadata;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
type FinalizeUserLoginInput = {
|
||||
req: ExpressRequest;
|
||||
res: ExpressResponse;
|
||||
@@ -63,9 +69,9 @@ type FinalizeUserLoginInput = {
|
||||
|
||||
const finalizeUserLogin: (
|
||||
data: FinalizeUserLoginInput,
|
||||
) => Promise<SessionMetadata> = async (
|
||||
) => Promise<FinalizeUserLoginResult> = async (
|
||||
data: FinalizeUserLoginInput,
|
||||
): Promise<SessionMetadata> => {
|
||||
): Promise<FinalizeUserLoginResult> => {
|
||||
const { req, res, user, isGlobalLogin } = data;
|
||||
|
||||
const sessionMetadata: SessionMetadata =
|
||||
@@ -87,7 +93,21 @@ const finalizeUserLogin: (
|
||||
accessTokenExpiresInSeconds: ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
});
|
||||
|
||||
return sessionMetadata;
|
||||
// Generate access token for response body (used by mobile clients)
|
||||
const accessToken: string = JSONWebToken.signUserLoginToken({
|
||||
tokenData: {
|
||||
userId: user.id!,
|
||||
email: user.email!,
|
||||
name: user.name!,
|
||||
timezone: user.timezone || null,
|
||||
isMasterAdmin: user.isMasterAdmin!,
|
||||
isGlobalLogin: isGlobalLogin,
|
||||
sessionId: sessionMetadata.session.id!,
|
||||
},
|
||||
expiresInSeconds: ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
});
|
||||
|
||||
return { sessionMetadata, accessToken };
|
||||
};
|
||||
|
||||
router.post(
|
||||
@@ -552,8 +572,10 @@ router.post(
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Try cookie first, then fallback to request body (for mobile clients)
|
||||
const refreshToken: string | undefined =
|
||||
CookieUtil.getRefreshTokenFromExpressRequest(req);
|
||||
CookieUtil.getRefreshTokenFromExpressRequest(req) ||
|
||||
(req.body.refreshToken as string | undefined);
|
||||
|
||||
if (!refreshToken) {
|
||||
CookieUtil.removeAllCookies(req, res);
|
||||
@@ -658,7 +680,26 @@ router.post(
|
||||
accessTokenExpiresInSeconds: ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
});
|
||||
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
// Generate access token for response body (used by mobile clients)
|
||||
const newAccessToken: string = JSONWebToken.signUserLoginToken({
|
||||
tokenData: {
|
||||
userId: user.id!,
|
||||
email: user.email!,
|
||||
name: user.name!,
|
||||
timezone: user.timezone || null,
|
||||
isMasterAdmin: user.isMasterAdmin!,
|
||||
isGlobalLogin: isGlobalLogin,
|
||||
sessionId: renewedSession.session.id!,
|
||||
},
|
||||
expiresInSeconds: ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||
});
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: renewedSession.refreshToken,
|
||||
refreshTokenExpiresAt:
|
||||
renewedSession.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
@@ -673,8 +714,10 @@ router.post(
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Try cookie first, then fallback to request body (for mobile clients)
|
||||
const refreshToken: string | undefined =
|
||||
CookieUtil.getRefreshTokenFromExpressRequest(req);
|
||||
CookieUtil.getRefreshTokenFromExpressRequest(req) ||
|
||||
(req.body.refreshToken as string | undefined);
|
||||
|
||||
if (refreshToken) {
|
||||
await UserSessionService.revokeSessionByRefreshToken(refreshToken, {
|
||||
@@ -987,14 +1030,21 @@ const login: LoginFunction = async (options: {
|
||||
if (alreadySavedUser.password.toString() === user.password!.toString()) {
|
||||
logger.info("User logged in: " + alreadySavedUser.email?.toString());
|
||||
|
||||
await finalizeUserLogin({
|
||||
const loginResult: FinalizeUserLoginResult = await finalizeUserLogin({
|
||||
req,
|
||||
res,
|
||||
user: alreadySavedUser,
|
||||
isGlobalLogin: true,
|
||||
});
|
||||
|
||||
return Response.sendEntityResponse(req, res, alreadySavedUser, User);
|
||||
return Response.sendEntityResponse(req, res, alreadySavedUser, User, {
|
||||
miscData: {
|
||||
accessToken: loginResult.accessToken,
|
||||
refreshToken: loginResult.sessionMetadata.refreshToken,
|
||||
refreshTokenExpiresAt:
|
||||
loginResult.sessionMetadata.refreshTokenExpiresAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return Response.sendErrorResponse(
|
||||
|
||||
@@ -16,6 +16,7 @@ import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import PushDeviceType from "../../Types/PushNotification/PushDeviceType";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
@TenantColumn("projectId")
|
||||
@@ -122,7 +123,7 @@ class UserPush extends BaseModel {
|
||||
unique: false,
|
||||
nullable: false,
|
||||
})
|
||||
public deviceType?: "web" = "web" as const; // Only web support for now
|
||||
public deviceType?: string = PushDeviceType.Web;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
|
||||
@@ -14,6 +14,7 @@ import Response from "../Utils/Response";
|
||||
import BaseAPI from "./BaseAPI";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import PushDeviceType from "../../Types/PushNotification/PushDeviceType";
|
||||
import UserPush from "../../Models/DatabaseModels/UserPush";
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
|
||||
@@ -39,11 +40,17 @@ export default class UserPushAPI extends BaseAPI<
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.body.deviceType || req.body.deviceType !== "web") {
|
||||
const validDeviceTypes: string[] = Object.values(PushDeviceType);
|
||||
if (
|
||||
!req.body.deviceType ||
|
||||
!validDeviceTypes.includes(req.body.deviceType)
|
||||
) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Only web device type is supported"),
|
||||
new BadDataException(
|
||||
"Device type must be one of: " + validDeviceTypes.join(", "),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +93,7 @@ export default class UserPushAPI extends BaseAPI<
|
||||
userPush.deviceToken = req.body.deviceToken;
|
||||
userPush.deviceType = req.body.deviceType;
|
||||
userPush.deviceName = req.body.deviceName || "Unknown Device";
|
||||
userPush.isVerified = true; // For web push, we consider it verified immediately
|
||||
userPush.isVerified = true; // Web, iOS, and Android devices are verified immediately
|
||||
|
||||
const savedDevice: UserPush = await this.service.create({
|
||||
data: userPush,
|
||||
@@ -186,7 +193,7 @@ export default class UserPushAPI extends BaseAPI<
|
||||
},
|
||||
],
|
||||
message: testMessage,
|
||||
deviceType: device.deviceType!,
|
||||
deviceType: device.deviceType! as PushDeviceType,
|
||||
},
|
||||
{
|
||||
isSensitive: false,
|
||||
|
||||
@@ -544,3 +544,14 @@ export const InboundEmailDomain: string | undefined =
|
||||
|
||||
export const InboundEmailWebhookSecret: string | undefined =
|
||||
process.env["INBOUND_EMAIL_WEBHOOK_SECRET"] || undefined;
|
||||
|
||||
// Firebase Cloud Messaging (FCM) Configuration for Native Push Notifications
|
||||
export const FirebaseProjectId: string | undefined =
|
||||
process.env["FIREBASE_PROJECT_ID"] || undefined;
|
||||
|
||||
export const FirebaseClientEmail: string | undefined =
|
||||
process.env["FIREBASE_CLIENT_EMAIL"] || undefined;
|
||||
|
||||
export const FirebasePrivateKey: string | null = decodePrivateKey(
|
||||
process.env["FIREBASE_PRIVATE_KEY"],
|
||||
);
|
||||
|
||||
@@ -64,18 +64,23 @@ export default class UserMiddleware {
|
||||
public static getAccessTokenFromExpressRequest(
|
||||
req: ExpressRequest,
|
||||
): string | undefined {
|
||||
let accessToken: string | undefined = undefined;
|
||||
// 1. Try cookie (existing web dashboard flow)
|
||||
const cookieToken: string | undefined =
|
||||
CookieUtil.getCookieFromExpressRequest(req, CookieUtil.getUserTokenKey());
|
||||
|
||||
if (
|
||||
CookieUtil.getCookieFromExpressRequest(req, CookieUtil.getUserTokenKey())
|
||||
) {
|
||||
accessToken = CookieUtil.getCookieFromExpressRequest(
|
||||
req,
|
||||
CookieUtil.getUserTokenKey(),
|
||||
);
|
||||
if (cookieToken) {
|
||||
return cookieToken;
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
// 2. Fallback: Check Authorization: Bearer <token> header (mobile app flow)
|
||||
const authHeader: string | undefined = req.headers[
|
||||
"authorization"
|
||||
] as string | undefined;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import PushNotificationRequest from "../../Types/PushNotification/PushNotificationRequest";
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
import PushDeviceType from "../../Types/PushNotification/PushDeviceType";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import logger from "../Utils/Logger";
|
||||
import UserPushService from "./UserPushService";
|
||||
@@ -9,8 +10,12 @@ import {
|
||||
VapidPublicKey,
|
||||
VapidPrivateKey,
|
||||
VapidSubject,
|
||||
FirebaseProjectId,
|
||||
FirebaseClientEmail,
|
||||
FirebasePrivateKey,
|
||||
} from "../EnvironmentConfig";
|
||||
import webpush from "web-push";
|
||||
import * as firebaseAdmin from "firebase-admin";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
||||
import UserPush from "../../Models/DatabaseModels/UserPush";
|
||||
@@ -41,6 +46,34 @@ export interface PushNotificationOptions {
|
||||
|
||||
export default class PushNotificationService {
|
||||
public static isWebPushInitialized = false;
|
||||
public static isFirebaseInitialized = false;
|
||||
|
||||
public static initializeFirebase(): void {
|
||||
if (this.isFirebaseInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FirebaseProjectId || !FirebaseClientEmail || !FirebasePrivateKey) {
|
||||
logger.warn(
|
||||
"Firebase credentials not configured. Native push notifications (iOS/Android) will not work.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
firebaseAdmin.initializeApp({
|
||||
credential: firebaseAdmin.credential.cert({
|
||||
projectId: FirebaseProjectId,
|
||||
clientEmail: FirebaseClientEmail,
|
||||
privateKey: FirebasePrivateKey,
|
||||
}),
|
||||
});
|
||||
this.isFirebaseInitialized = true;
|
||||
logger.info("Firebase Admin SDK initialized successfully");
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to initialize Firebase Admin SDK: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static initializeWebPush(): void {
|
||||
if (this.isWebPushInitialized) {
|
||||
@@ -76,13 +109,8 @@ export default class PushNotificationService {
|
||||
throw new Error("No devices provided");
|
||||
}
|
||||
|
||||
if (request.deviceType !== "web") {
|
||||
logger.error(`Unsupported device type: ${request.deviceType}`);
|
||||
throw new Error("Only web push notifications are supported");
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Sending web push notifications to ${request.devices.length} devices`,
|
||||
`Sending ${request.deviceType} push notifications to ${request.devices.length} devices`,
|
||||
);
|
||||
logger.info(`Notification message: ${JSON.stringify(request.message)}`);
|
||||
|
||||
@@ -98,9 +126,25 @@ export default class PushNotificationService {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const device of request.devices) {
|
||||
promises.push(
|
||||
this.sendWebPushNotification(device.token, request.message, options),
|
||||
);
|
||||
if (request.deviceType === PushDeviceType.Web) {
|
||||
promises.push(
|
||||
this.sendWebPushNotification(device.token, request.message, options),
|
||||
);
|
||||
} else if (
|
||||
request.deviceType === PushDeviceType.iOS ||
|
||||
request.deviceType === PushDeviceType.Android
|
||||
) {
|
||||
promises.push(
|
||||
this.sendFcmPushNotification(
|
||||
device.token,
|
||||
request.message,
|
||||
request.deviceType,
|
||||
options,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
logger.error(`Unsupported device type: ${request.deviceType}`);
|
||||
}
|
||||
}
|
||||
|
||||
const results: Array<any> = await Promise.allSettled(promises);
|
||||
@@ -314,6 +358,77 @@ export default class PushNotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
private static async sendFcmPushNotification(
|
||||
fcmToken: string,
|
||||
message: PushNotificationMessage,
|
||||
deviceType: PushDeviceType,
|
||||
_options: PushNotificationOptions,
|
||||
): Promise<void> {
|
||||
if (!this.isFirebaseInitialized) {
|
||||
this.initializeFirebase();
|
||||
}
|
||||
|
||||
if (!this.isFirebaseInitialized) {
|
||||
throw new Error("Firebase Admin SDK not configured");
|
||||
}
|
||||
|
||||
try {
|
||||
const dataPayload: { [key: string]: string } = {};
|
||||
if (message.data) {
|
||||
for (const key of Object.keys(message.data)) {
|
||||
dataPayload[key] = String(message.data[key]);
|
||||
}
|
||||
}
|
||||
if (message.url || message.clickAction) {
|
||||
dataPayload["url"] = message.url || message.clickAction || "";
|
||||
}
|
||||
|
||||
const fcmMessage: firebaseAdmin.messaging.Message = {
|
||||
token: fcmToken,
|
||||
notification: {
|
||||
title: message.title,
|
||||
body: message.body,
|
||||
},
|
||||
data: dataPayload,
|
||||
android: {
|
||||
priority: "high" as const,
|
||||
notification: {
|
||||
sound: "default",
|
||||
channelId: "oncall_high",
|
||||
},
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: "default",
|
||||
badge: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await firebaseAdmin.messaging().send(fcmMessage);
|
||||
|
||||
logger.info(
|
||||
`FCM push notification sent successfully to ${deviceType} device`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Failed to send FCM push notification to ${deviceType} device: ${error.message}`,
|
||||
);
|
||||
|
||||
// If the token is invalid, log it
|
||||
if (
|
||||
error.code === "messaging/invalid-registration-token" ||
|
||||
error.code === "messaging/registration-token-not-registered"
|
||||
) {
|
||||
logger.info("FCM token is invalid or unregistered");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async sendPushNotificationToUser(
|
||||
userId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
@@ -342,33 +457,46 @@ export default class PushNotificationService {
|
||||
|
||||
if (userPushDevices.length === 0) {
|
||||
logger.info(
|
||||
`No verified web push devices found for user ${userId.toString()}`,
|
||||
`No verified push devices found for user ${userId.toString()}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get web devices with tokens and names
|
||||
const webDevices: Array<{ token: string; name?: string }> = [];
|
||||
// Group devices by type
|
||||
const devicesByType: Map<
|
||||
string,
|
||||
Array<{ token: string; name?: string }>
|
||||
> = new Map();
|
||||
|
||||
for (const device of userPushDevices) {
|
||||
if (device.deviceType === "web") {
|
||||
webDevices.push({
|
||||
token: device.deviceToken!,
|
||||
name: device.deviceName || "Unknown Device",
|
||||
});
|
||||
const type: string = device.deviceType || PushDeviceType.Web;
|
||||
if (!devicesByType.has(type)) {
|
||||
devicesByType.set(type, []);
|
||||
}
|
||||
devicesByType.get(type)!.push({
|
||||
token: device.deviceToken!,
|
||||
name: device.deviceName || "Unknown Device",
|
||||
});
|
||||
}
|
||||
|
||||
// Send notifications to each device type group
|
||||
const sendPromises: Promise<void>[] = [];
|
||||
|
||||
for (const [deviceType, devices] of devicesByType.entries()) {
|
||||
if (devices.length > 0) {
|
||||
sendPromises.push(
|
||||
this.sendPushNotification(
|
||||
{
|
||||
devices: devices,
|
||||
message: message,
|
||||
deviceType: deviceType as PushDeviceType,
|
||||
},
|
||||
options,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send notifications to web devices
|
||||
if (webDevices.length > 0) {
|
||||
await this.sendPushNotification(
|
||||
{
|
||||
devices: webDevices,
|
||||
message: message,
|
||||
deviceType: "web",
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
await Promise.allSettled(sendPromises);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import EmailTemplateType from "../../Types/Email/EmailTemplateType";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import NotificationRuleType from "../../Types/NotificationRule/NotificationRuleType";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import PushDeviceType from "../../Types/PushNotification/PushDeviceType";
|
||||
import Phone from "../../Types/Phone";
|
||||
import SMS from "../../Types/SMS/SMS";
|
||||
import WhatsAppMessage from "../../Types/WhatsApp/WhatsAppMessage";
|
||||
@@ -1115,7 +1116,7 @@ export class Service extends DatabaseService<Model> {
|
||||
},
|
||||
],
|
||||
message: pushMessage,
|
||||
deviceType: notificationRuleItem.userPush.deviceType!,
|
||||
deviceType: notificationRuleItem.userPush.deviceType! as PushDeviceType,
|
||||
},
|
||||
{
|
||||
projectId: options.projectId,
|
||||
@@ -1189,7 +1190,7 @@ export class Service extends DatabaseService<Model> {
|
||||
},
|
||||
],
|
||||
message: pushMessage,
|
||||
deviceType: notificationRuleItem.userPush.deviceType!,
|
||||
deviceType: notificationRuleItem.userPush.deviceType! as PushDeviceType,
|
||||
},
|
||||
{
|
||||
projectId: options.projectId,
|
||||
@@ -1264,7 +1265,7 @@ export class Service extends DatabaseService<Model> {
|
||||
},
|
||||
],
|
||||
message: pushMessage,
|
||||
deviceType: notificationRuleItem.userPush.deviceType!,
|
||||
deviceType: notificationRuleItem.userPush.deviceType! as PushDeviceType,
|
||||
},
|
||||
{
|
||||
projectId: options.projectId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { OnCreate, OnDelete } from "../Types/Database/Hooks";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import PushDeviceType from "../../Types/PushNotification/PushDeviceType";
|
||||
import UserPush from "../../Models/DatabaseModels/UserPush";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
|
||||
@@ -25,7 +26,7 @@ export class Service extends DatabaseService<UserPush> {
|
||||
}
|
||||
|
||||
// Validate device type
|
||||
const validDeviceTypes: string[] = ["web", "android", "ios"];
|
||||
const validDeviceTypes: string[] = Object.values(PushDeviceType);
|
||||
if (!validDeviceTypes.includes(createBy.data.deviceType)) {
|
||||
throw new BadDataException(
|
||||
"Device type must be one of: " + validDeviceTypes.join(", "),
|
||||
|
||||
7
Common/Types/PushNotification/PushDeviceType.ts
Normal file
7
Common/Types/PushNotification/PushDeviceType.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
enum PushDeviceType {
|
||||
Web = "web",
|
||||
iOS = "ios",
|
||||
Android = "android",
|
||||
}
|
||||
|
||||
export default PushDeviceType;
|
||||
@@ -1,3 +1,5 @@
|
||||
import PushDeviceType from "./PushDeviceType";
|
||||
|
||||
interface PushNotificationRequest {
|
||||
devices: Array<{
|
||||
token: string;
|
||||
@@ -19,7 +21,7 @@ interface PushNotificationRequest {
|
||||
clickAction?: string;
|
||||
url?: string;
|
||||
};
|
||||
deviceType: "web";
|
||||
deviceType: PushDeviceType;
|
||||
}
|
||||
|
||||
export default PushNotificationRequest;
|
||||
|
||||
880
Common/package-lock.json
generated
880
Common/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,7 @@
|
||||
"elkjs": "^0.10.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"express": "^4.21.1",
|
||||
"firebase-admin": "^13.6.1",
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
|
||||
41
MobileApp/.gitignore
vendored
Normal file
41
MobileApp/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
1
MobileApp/App.tsx
Normal file
1
MobileApp/App.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./src/App";
|
||||
32
MobileApp/app.json
Normal file
32
MobileApp/app.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "OneUptime",
|
||||
"slug": "oneuptime",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"scheme": "oneuptime",
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0D1117"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.oneuptime.oncall"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0D1117"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"package": "com.oneuptime.oncall"
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
MobileApp/assets/adaptive-icon.png
Normal file
BIN
MobileApp/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
MobileApp/assets/favicon.png
Normal file
BIN
MobileApp/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
MobileApp/assets/icon.png
Normal file
BIN
MobileApp/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
MobileApp/assets/splash-icon.png
Normal file
BIN
MobileApp/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
8
MobileApp/index.ts
Normal file
8
MobileApp/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
9441
MobileApp/package-lock.json
generated
Normal file
9441
MobileApp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
MobileApp/package.json
Normal file
32
MobileApp/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "mobileapp",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.12.0",
|
||||
"@react-navigation/native": "^7.1.28",
|
||||
"@react-navigation/native-stack": "^7.12.0",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"axios": "^1.13.5",
|
||||
"expo": "~54.0.33",
|
||||
"expo-splash-screen": "^31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-keychain": "^10.0.0",
|
||||
"react-native-safe-area-context": "^5.6.2",
|
||||
"react-native-screens": "^4.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
31
MobileApp/src/App.tsx
Normal file
31
MobileApp/src/App.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider, useTheme } from "./theme";
|
||||
import { AuthProvider } from "./hooks/useAuth";
|
||||
import RootNavigator from "./navigation/RootNavigator";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function AppContent(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style={theme.isDark ? "light" : "dark"} />
|
||||
<RootNavigator />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App(): React.JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
104
MobileApp/src/api/auth.ts
Normal file
104
MobileApp/src/api/auth.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import axios from "axios";
|
||||
import apiClient from "./client";
|
||||
import { getServerUrl } from "../storage/serverUrl";
|
||||
import { storeTokens, clearTokens } from "../storage/keychain";
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
refreshTokenExpiresAt: string;
|
||||
user: {
|
||||
_id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isMasterAdmin: boolean;
|
||||
};
|
||||
twoFactorRequired?: boolean;
|
||||
}
|
||||
|
||||
export async function validateServerUrl(url: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get(`${url}/api/status`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
return response.status === 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<LoginResponse> {
|
||||
const serverUrl = await getServerUrl();
|
||||
|
||||
const response = await apiClient.post(
|
||||
`${serverUrl}/identity/login`,
|
||||
{
|
||||
data: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Don't use the interceptor's baseURL for login
|
||||
baseURL: "",
|
||||
},
|
||||
);
|
||||
|
||||
const responseData = response.data;
|
||||
|
||||
// Check if 2FA is required
|
||||
if (
|
||||
responseData.miscData?.totpAuthList ||
|
||||
responseData.miscData?.webAuthnList
|
||||
) {
|
||||
return {
|
||||
...responseData,
|
||||
twoFactorRequired: true,
|
||||
accessToken: "",
|
||||
refreshToken: "",
|
||||
refreshTokenExpiresAt: "",
|
||||
user: responseData.data || {},
|
||||
};
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, refreshTokenExpiresAt } =
|
||||
responseData.miscData || {};
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
await storeTokens({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt,
|
||||
user: responseData.data || {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
const serverUrl = await getServerUrl();
|
||||
const { getTokens } = await import("../storage/keychain");
|
||||
const tokens = await getTokens();
|
||||
|
||||
if (tokens?.refreshToken) {
|
||||
await apiClient.post(
|
||||
`${serverUrl}/identity/logout`,
|
||||
{ refreshToken: tokens.refreshToken },
|
||||
{ baseURL: "" },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Logout failures should not block the flow
|
||||
} finally {
|
||||
await clearTokens();
|
||||
}
|
||||
}
|
||||
119
MobileApp/src/api/client.ts
Normal file
119
MobileApp/src/api/client.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosError } from "axios";
|
||||
import { getServerUrl } from "../storage/serverUrl";
|
||||
import {
|
||||
getCachedAccessToken,
|
||||
getTokens,
|
||||
storeTokens,
|
||||
clearTokens,
|
||||
} from "../storage/keychain";
|
||||
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers: Array<(token: string) => void> = [];
|
||||
let onAuthFailure: (() => void) | null = null;
|
||||
|
||||
function subscribeTokenRefresh(callback: (token: string) => void): void {
|
||||
refreshSubscribers.push(callback);
|
||||
}
|
||||
|
||||
function onTokenRefreshed(newToken: string): void {
|
||||
refreshSubscribers.forEach((callback) => {
|
||||
callback(newToken);
|
||||
});
|
||||
refreshSubscribers = [];
|
||||
}
|
||||
|
||||
export function setOnAuthFailure(callback: () => void): void {
|
||||
onAuthFailure = callback;
|
||||
}
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor: attach base URL and Bearer token
|
||||
apiClient.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
if (!config.baseURL) {
|
||||
config.baseURL = await getServerUrl();
|
||||
}
|
||||
|
||||
const token = getCachedAccessToken();
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
);
|
||||
|
||||
// Response interceptor: handle 401 with token refresh queue
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
if (error.response?.status !== 401 || originalRequest._retry) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
subscribeTokenRefresh((newToken: string) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
resolve(apiClient(originalRequest));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const tokens = await getTokens();
|
||||
if (!tokens?.refreshToken) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
const serverUrl = await getServerUrl();
|
||||
const response = await axios.post(`${serverUrl}/identity/refresh-token`, {
|
||||
refreshToken: tokens.refreshToken,
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken, refreshTokenExpiresAt } =
|
||||
response.data;
|
||||
|
||||
await storeTokens({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt,
|
||||
});
|
||||
|
||||
onTokenRefreshed(accessToken);
|
||||
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return apiClient(originalRequest);
|
||||
} catch {
|
||||
await clearTokens();
|
||||
if (onAuthFailure) {
|
||||
onAuthFailure();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
51
MobileApp/src/components/EmptyState.tsx
Normal file
51
MobileApp/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export default function EmptyState({
|
||||
title,
|
||||
subtitle,
|
||||
}: EmptyStateProps): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.titleSmall,
|
||||
{ color: theme.colors.textPrimary, textAlign: "center" },
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{
|
||||
color: theme.colors.textSecondary,
|
||||
textAlign: "center",
|
||||
marginTop: theme.spacing.sm,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
});
|
||||
45
MobileApp/src/components/ProjectBadge.tsx
Normal file
45
MobileApp/src/components/ProjectBadge.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
interface ProjectBadgeProps {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export default function ProjectBadge({
|
||||
name,
|
||||
color,
|
||||
}: ProjectBadgeProps): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const dotColor = color || theme.colors.actionPrimary;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.dot, { backgroundColor: dotColor }]} />
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.textSecondary },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 6,
|
||||
},
|
||||
});
|
||||
70
MobileApp/src/components/SeverityBadge.tsx
Normal file
70
MobileApp/src/components/SeverityBadge.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
export type SeverityLevel =
|
||||
| "critical"
|
||||
| "major"
|
||||
| "minor"
|
||||
| "warning"
|
||||
| "info";
|
||||
|
||||
interface SeverityBadgeProps {
|
||||
severity: SeverityLevel;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function SeverityBadge({
|
||||
severity,
|
||||
label,
|
||||
}: SeverityBadgeProps): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const colorMap: Record<SeverityLevel, { text: string; bg: string }> = {
|
||||
critical: {
|
||||
text: theme.colors.severityCritical,
|
||||
bg: theme.colors.severityCriticalBg,
|
||||
},
|
||||
major: {
|
||||
text: theme.colors.severityMajor,
|
||||
bg: theme.colors.severityMajorBg,
|
||||
},
|
||||
minor: {
|
||||
text: theme.colors.severityMinor,
|
||||
bg: theme.colors.severityMinorBg,
|
||||
},
|
||||
warning: {
|
||||
text: theme.colors.severityWarning,
|
||||
bg: theme.colors.severityWarningBg,
|
||||
},
|
||||
info: {
|
||||
text: theme.colors.severityInfo,
|
||||
bg: theme.colors.severityInfoBg,
|
||||
},
|
||||
};
|
||||
|
||||
const colors = colorMap[severity];
|
||||
const displayLabel = label || severity;
|
||||
|
||||
return (
|
||||
<View style={[styles.badge, { backgroundColor: colors.bg }]}>
|
||||
<Text style={[styles.text, { color: colors.text }]}>
|
||||
{displayLabel.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
91
MobileApp/src/components/SkeletonCard.tsx
Normal file
91
MobileApp/src/components/SkeletonCard.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { View, StyleSheet, Animated, DimensionValue } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
interface SkeletonCardProps {
|
||||
lines?: number;
|
||||
}
|
||||
|
||||
export default function SkeletonCard({
|
||||
lines = 3,
|
||||
}: SkeletonCardProps): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const opacity = useRef(new Animated.Value(0.3)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0.3,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
animation.start();
|
||||
|
||||
return () => {
|
||||
animation.stop();
|
||||
};
|
||||
}, [opacity]);
|
||||
|
||||
const lineWidths: DimensionValue[] = ["60%", "80%", "45%", "70%"];
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
opacity,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.titleLine,
|
||||
{ backgroundColor: theme.colors.backgroundTertiary },
|
||||
]}
|
||||
/>
|
||||
{Array.from({ length: lines }).map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.line,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundTertiary,
|
||||
width: lineWidths[index % lineWidths.length],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
marginBottom: 12,
|
||||
},
|
||||
titleLine: {
|
||||
height: 16,
|
||||
borderRadius: 4,
|
||||
width: "40%",
|
||||
marginBottom: 16,
|
||||
},
|
||||
line: {
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
});
|
||||
75
MobileApp/src/components/StateBadge.tsx
Normal file
75
MobileApp/src/components/StateBadge.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
export type StateType =
|
||||
| "created"
|
||||
| "acknowledged"
|
||||
| "resolved"
|
||||
| "investigating"
|
||||
| "muted";
|
||||
|
||||
interface StateBadgeProps {
|
||||
state: StateType;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function StateBadge({
|
||||
state,
|
||||
label,
|
||||
}: StateBadgeProps): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const colorMap: Record<StateType, string> = {
|
||||
created: theme.colors.stateCreated,
|
||||
acknowledged: theme.colors.stateAcknowledged,
|
||||
resolved: theme.colors.stateResolved,
|
||||
investigating: theme.colors.stateInvestigating,
|
||||
muted: theme.colors.stateMuted,
|
||||
};
|
||||
|
||||
const color = colorMap[state];
|
||||
const displayLabel = label || state;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundTertiary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.dot, { backgroundColor: color }]} />
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
{displayLabel.charAt(0).toUpperCase() + displayLabel.slice(1)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 6,
|
||||
},
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
117
MobileApp/src/hooks/useAuth.tsx
Normal file
117
MobileApp/src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { getTokens, clearTokens } from "../storage/keychain";
|
||||
import { hasServerUrl } from "../storage/serverUrl";
|
||||
import {
|
||||
login as apiLogin,
|
||||
logout as apiLogout,
|
||||
LoginResponse,
|
||||
} from "../api/auth";
|
||||
import { setOnAuthFailure } from "../api/client";
|
||||
|
||||
interface AuthContextValue {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
needsServerUrl: boolean;
|
||||
user: LoginResponse["user"] | null;
|
||||
login: (email: string, password: string) => Promise<LoginResponse>;
|
||||
logout: () => Promise<void>;
|
||||
setNeedsServerUrl: (value: boolean) => void;
|
||||
setIsAuthenticated: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps): React.JSX.Element {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [needsServerUrl, setNeedsServerUrl] = useState(false);
|
||||
const [user, setUser] = useState<LoginResponse["user"] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async (): Promise<void> => {
|
||||
try {
|
||||
const hasUrl = await hasServerUrl();
|
||||
if (!hasUrl) {
|
||||
setNeedsServerUrl(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = await getTokens();
|
||||
if (tokens?.accessToken) {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch {
|
||||
// If anything fails, user needs to re-authenticate
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// Register auth failure handler for 401 interceptor
|
||||
useEffect(() => {
|
||||
setOnAuthFailure(() => {
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string): Promise<LoginResponse> => {
|
||||
const response = await apiLogin(email, password);
|
||||
|
||||
if (!response.twoFactorRequired && response.accessToken) {
|
||||
setIsAuthenticated(true);
|
||||
setUser(response.user);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const logout = useCallback(async (): Promise<void> => {
|
||||
await apiLogout();
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
needsServerUrl,
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
setNeedsServerUrl,
|
||||
setIsAuthenticated,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
34
MobileApp/src/navigation/AuthStackNavigator.tsx
Normal file
34
MobileApp/src/navigation/AuthStackNavigator.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import { AuthStackParamList } from "./types";
|
||||
import ServerUrlScreen from "../screens/auth/ServerUrlScreen";
|
||||
import LoginScreen from "../screens/auth/LoginScreen";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
const Stack = createNativeStackNavigator<AuthStackParamList>();
|
||||
|
||||
interface AuthStackNavigatorProps {
|
||||
initialRoute: keyof AuthStackParamList;
|
||||
}
|
||||
|
||||
export default function AuthStackNavigator({
|
||||
initialRoute,
|
||||
}: AuthStackNavigatorProps): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<Stack.Navigator
|
||||
initialRouteName={initialRoute}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
},
|
||||
animation: "slide_from_right",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="ServerUrl" component={ServerUrlScreen} />
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
36
MobileApp/src/navigation/MainTabNavigator.tsx
Normal file
36
MobileApp/src/navigation/MainTabNavigator.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { MainTabParamList } from "./types";
|
||||
import HomeScreen from "../screens/HomeScreen";
|
||||
import IncidentsScreen from "../screens/IncidentsScreen";
|
||||
import AlertsScreen from "../screens/AlertsScreen";
|
||||
import SettingsScreen from "../screens/SettingsScreen";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||
|
||||
export default function MainTabNavigator(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
},
|
||||
headerTintColor: theme.colors.textPrimary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderTopColor: theme.colors.borderDefault,
|
||||
},
|
||||
tabBarActiveTintColor: theme.colors.actionPrimary,
|
||||
tabBarInactiveTintColor: theme.colors.textTertiary,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name="Home" component={HomeScreen} />
|
||||
<Tab.Screen name="Incidents" component={IncidentsScreen} />
|
||||
<Tab.Screen name="Alerts" component={AlertsScreen} />
|
||||
<Tab.Screen name="Settings" component={SettingsScreen} />
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
60
MobileApp/src/navigation/RootNavigator.tsx
Normal file
60
MobileApp/src/navigation/RootNavigator.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import { NavigationContainer, DefaultTheme, Theme } from "@react-navigation/native";
|
||||
import { useTheme } from "../theme";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import AuthStackNavigator from "./AuthStackNavigator";
|
||||
import MainTabNavigator from "./MainTabNavigator";
|
||||
import { ActivityIndicator, View, StyleSheet } from "react-native";
|
||||
|
||||
export default function RootNavigator(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const { isAuthenticated, isLoading, needsServerUrl } = useAuth();
|
||||
|
||||
const navigationTheme: Theme = {
|
||||
...DefaultTheme,
|
||||
dark: theme.isDark,
|
||||
colors: {
|
||||
...DefaultTheme.colors,
|
||||
primary: theme.colors.actionPrimary,
|
||||
background: theme.colors.backgroundPrimary,
|
||||
card: theme.colors.backgroundSecondary,
|
||||
text: theme.colors.textPrimary,
|
||||
border: theme.colors.borderDefault,
|
||||
notification: theme.colors.severityCritical,
|
||||
},
|
||||
fonts: DefaultTheme.fonts,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.loading,
|
||||
{ backgroundColor: theme.colors.backgroundPrimary },
|
||||
]}
|
||||
>
|
||||
<ActivityIndicator size="large" color={theme.colors.actionPrimary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer theme={navigationTheme}>
|
||||
{isAuthenticated ? (
|
||||
<MainTabNavigator />
|
||||
) : (
|
||||
<AuthStackNavigator
|
||||
initialRoute={needsServerUrl ? "ServerUrl" : "Login"}
|
||||
/>
|
||||
)}
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loading: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
11
MobileApp/src/navigation/types.ts
Normal file
11
MobileApp/src/navigation/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type AuthStackParamList = {
|
||||
ServerUrl: undefined;
|
||||
Login: undefined;
|
||||
};
|
||||
|
||||
export type MainTabParamList = {
|
||||
Home: undefined;
|
||||
Incidents: undefined;
|
||||
Alerts: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
36
MobileApp/src/screens/AlertsScreen.tsx
Normal file
36
MobileApp/src/screens/AlertsScreen.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
export default function AlertsScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: theme.colors.backgroundPrimary },
|
||||
]}
|
||||
>
|
||||
<Text style={[theme.typography.titleMedium, { color: theme.colors.textPrimary }]}>
|
||||
Alerts
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textSecondary, marginTop: theme.spacing.sm },
|
||||
]}
|
||||
>
|
||||
Coming soon
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
36
MobileApp/src/screens/HomeScreen.tsx
Normal file
36
MobileApp/src/screens/HomeScreen.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
export default function HomeScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: theme.colors.backgroundPrimary },
|
||||
]}
|
||||
>
|
||||
<Text style={[theme.typography.titleMedium, { color: theme.colors.textPrimary }]}>
|
||||
Home
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textSecondary, marginTop: theme.spacing.sm },
|
||||
]}
|
||||
>
|
||||
Welcome to OneUptime
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
36
MobileApp/src/screens/IncidentsScreen.tsx
Normal file
36
MobileApp/src/screens/IncidentsScreen.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
export default function IncidentsScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: theme.colors.backgroundPrimary },
|
||||
]}
|
||||
>
|
||||
<Text style={[theme.typography.titleMedium, { color: theme.colors.textPrimary }]}>
|
||||
Incidents
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textSecondary, marginTop: theme.spacing.sm },
|
||||
]}
|
||||
>
|
||||
Coming soon
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
52
MobileApp/src/screens/SettingsScreen.tsx
Normal file
52
MobileApp/src/screens/SettingsScreen.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
export default function SettingsScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const { logout } = useAuth();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: theme.colors.backgroundPrimary },
|
||||
]}
|
||||
>
|
||||
<Text style={[theme.typography.titleMedium, { color: theme.colors.textPrimary }]}>
|
||||
Settings
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.logoutButton,
|
||||
{ backgroundColor: theme.colors.actionDestructive },
|
||||
]}
|
||||
onPress={logout}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textInverse, fontWeight: "600" },
|
||||
]}
|
||||
>
|
||||
Log Out
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
logoutButton: {
|
||||
marginTop: 24,
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
},
|
||||
});
|
||||
270
MobileApp/src/screens/auth/LoginScreen.tsx
Normal file
270
MobileApp/src/screens/auth/LoginScreen.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { useTheme } from "../../theme";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import { getServerUrl } from "../../storage/serverUrl";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { AuthStackParamList } from "../../navigation/types";
|
||||
|
||||
type LoginNavigationProp = NativeStackNavigationProp<
|
||||
AuthStackParamList,
|
||||
"Login"
|
||||
>;
|
||||
|
||||
export default function LoginScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const { login, setNeedsServerUrl } = useAuth();
|
||||
const navigation = useNavigation<LoginNavigationProp>();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [serverUrl, setServerUrlState] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getServerUrl().then(setServerUrlState);
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (): Promise<void> => {
|
||||
if (!email.trim() || !password.trim()) {
|
||||
setError("Email and password are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await login(email.trim(), password);
|
||||
|
||||
if (response.twoFactorRequired) {
|
||||
setError(
|
||||
"Two-factor authentication is not yet supported in the mobile app. Please disable 2FA temporarily or use the web dashboard.",
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
"Login failed. Please check your credentials.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeServer = (): void => {
|
||||
setNeedsServerUrl(true);
|
||||
navigation.navigate("ServerUrl");
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.flex, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.titleLarge,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
OneUptime
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{
|
||||
color: theme.colors.textTertiary,
|
||||
marginTop: theme.spacing.xs,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{serverUrl}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{
|
||||
color: theme.colors.textSecondary,
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Email
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderDefault,
|
||||
color: theme.colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
value={email}
|
||||
onChangeText={(text) => {
|
||||
setEmail(text);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="you@example.com"
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
textContentType="emailAddress"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{
|
||||
color: theme.colors.textSecondary,
|
||||
marginBottom: theme.spacing.xs,
|
||||
marginTop: theme.spacing.md,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Password
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.borderDefault,
|
||||
color: theme.colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
value={password}
|
||||
onChangeText={(text) => {
|
||||
setPassword(text);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Your password"
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
secureTextEntry
|
||||
textContentType="password"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={handleLogin}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{
|
||||
color: theme.colors.statusError,
|
||||
marginTop: theme.spacing.sm,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
backgroundColor: theme.colors.actionPrimary,
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
},
|
||||
]}
|
||||
onPress={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={theme.colors.textInverse} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textInverse, fontWeight: "600" },
|
||||
]}
|
||||
>
|
||||
Log In
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.changeServer}
|
||||
onPress={handleChangeServer}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{ color: theme.colors.actionPrimary },
|
||||
]}
|
||||
>
|
||||
Change Server
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
header: {
|
||||
alignItems: "center",
|
||||
marginBottom: 48,
|
||||
},
|
||||
form: {
|
||||
width: "100%",
|
||||
},
|
||||
input: {
|
||||
height: 56,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 16,
|
||||
},
|
||||
button: {
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 24,
|
||||
},
|
||||
changeServer: {
|
||||
alignItems: "center",
|
||||
marginTop: 24,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
});
|
||||
225
MobileApp/src/screens/auth/ServerUrlScreen.tsx
Normal file
225
MobileApp/src/screens/auth/ServerUrlScreen.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { AuthStackParamList } from "../../navigation/types";
|
||||
import { useTheme } from "../../theme";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import { setServerUrl } from "../../storage/serverUrl";
|
||||
import { validateServerUrl } from "../../api/auth";
|
||||
|
||||
type ServerUrlNavigationProp = NativeStackNavigationProp<
|
||||
AuthStackParamList,
|
||||
"ServerUrl"
|
||||
>;
|
||||
|
||||
export default function ServerUrlScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const { setNeedsServerUrl } = useAuth();
|
||||
const navigation = useNavigation<ServerUrlNavigationProp>();
|
||||
const [url, setUrl] = useState("https://oneuptime.com");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleConnect = async (): Promise<void> => {
|
||||
if (!url.trim()) {
|
||||
setError("Please enter a server URL");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const normalizedUrl = url.trim().replace(/\/+$/, "");
|
||||
const isValid = await validateServerUrl(normalizedUrl);
|
||||
|
||||
if (!isValid) {
|
||||
setError(
|
||||
"Could not connect to the server. Please check the URL and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setServerUrl(normalizedUrl);
|
||||
setNeedsServerUrl(false);
|
||||
navigation.navigate("Login");
|
||||
} catch {
|
||||
setError("An unexpected error occurred. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.flex, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.titleLarge,
|
||||
{ color: theme.colors.textPrimary },
|
||||
]}
|
||||
>
|
||||
OneUptime
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{
|
||||
color: theme.colors.textSecondary,
|
||||
marginTop: theme.spacing.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
]}
|
||||
>
|
||||
Connect to your OneUptime instance
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{
|
||||
color: theme.colors.textSecondary,
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Server URL
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: error
|
||||
? theme.colors.statusError
|
||||
: theme.colors.borderDefault,
|
||||
color: theme.colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
value={url}
|
||||
onChangeText={(text) => {
|
||||
setUrl(text);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="https://oneuptime.com"
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={handleConnect}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodySmall,
|
||||
{
|
||||
color: theme.colors.statusError,
|
||||
marginTop: theme.spacing.sm,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
backgroundColor: theme.colors.actionPrimary,
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
},
|
||||
]}
|
||||
onPress={handleConnect}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={theme.colors.textInverse} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.bodyMedium,
|
||||
{ color: theme.colors.textInverse, fontWeight: "600" },
|
||||
]}
|
||||
>
|
||||
Connect
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.caption,
|
||||
{
|
||||
color: theme.colors.textTertiary,
|
||||
textAlign: "center",
|
||||
marginTop: theme.spacing.lg,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Self-hosting? Enter your OneUptime server URL above.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
header: {
|
||||
alignItems: "center",
|
||||
marginBottom: 48,
|
||||
},
|
||||
form: {
|
||||
width: "100%",
|
||||
},
|
||||
input: {
|
||||
height: 56,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 16,
|
||||
},
|
||||
button: {
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 16,
|
||||
},
|
||||
});
|
||||
50
MobileApp/src/storage/keychain.ts
Normal file
50
MobileApp/src/storage/keychain.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as Keychain from "react-native-keychain";
|
||||
|
||||
const SERVICE_NAME = "com.oneuptime.oncall.tokens";
|
||||
|
||||
export interface StoredTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
refreshTokenExpiresAt: string;
|
||||
}
|
||||
|
||||
// In-memory cache for fast synchronous access
|
||||
let cachedAccessToken: string | null = null;
|
||||
|
||||
export function getCachedAccessToken(): string | null {
|
||||
return cachedAccessToken;
|
||||
}
|
||||
|
||||
export async function storeTokens(tokens: StoredTokens): Promise<void> {
|
||||
cachedAccessToken = tokens.accessToken;
|
||||
await Keychain.setGenericPassword(
|
||||
"tokens",
|
||||
JSON.stringify(tokens),
|
||||
{ service: SERVICE_NAME },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTokens(): Promise<StoredTokens | null> {
|
||||
const credentials = await Keychain.getGenericPassword({
|
||||
service: SERVICE_NAME,
|
||||
});
|
||||
|
||||
if (!credentials || typeof credentials === "boolean") {
|
||||
cachedAccessToken = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens: StoredTokens = JSON.parse(credentials.password);
|
||||
cachedAccessToken = tokens.accessToken;
|
||||
return tokens;
|
||||
} catch {
|
||||
cachedAccessToken = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearTokens(): Promise<void> {
|
||||
cachedAccessToken = null;
|
||||
await Keychain.resetGenericPassword({ service: SERVICE_NAME });
|
||||
}
|
||||
26
MobileApp/src/storage/serverUrl.ts
Normal file
26
MobileApp/src/storage/serverUrl.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
const STORAGE_KEY = "oneuptime_server_url";
|
||||
const DEFAULT_SERVER_URL = "https://oneuptime.com";
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export async function getServerUrl(): Promise<string> {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
return stored || DEFAULT_SERVER_URL;
|
||||
}
|
||||
|
||||
export async function setServerUrl(url: string): Promise<void> {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, normalizeUrl(url));
|
||||
}
|
||||
|
||||
export async function hasServerUrl(): Promise<boolean> {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
return stored !== null;
|
||||
}
|
||||
|
||||
export async function clearServerUrl(): Promise<void> {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
77
MobileApp/src/theme/ThemeContext.tsx
Normal file
77
MobileApp/src/theme/ThemeContext.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useMemo,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { ColorTokens, darkColors, lightColors } from "./colors";
|
||||
import { typography } from "./typography";
|
||||
import { spacing, radius } from "./spacing";
|
||||
|
||||
export type ThemeMode = "dark" | "light" | "system";
|
||||
|
||||
export interface Theme {
|
||||
colors: ColorTokens;
|
||||
typography: typeof typography;
|
||||
spacing: typeof spacing;
|
||||
radius: typeof radius;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
themeMode: ThemeMode;
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Element {
|
||||
const systemColorScheme = useColorScheme();
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||
|
||||
const theme = useMemo((): Theme => {
|
||||
let isDark: boolean;
|
||||
|
||||
if (themeMode === "system") {
|
||||
isDark = systemColorScheme !== "light";
|
||||
} else {
|
||||
isDark = themeMode === "dark";
|
||||
}
|
||||
|
||||
return {
|
||||
colors: isDark ? darkColors : lightColors,
|
||||
typography,
|
||||
spacing,
|
||||
radius,
|
||||
isDark,
|
||||
};
|
||||
}, [themeMode, systemColorScheme]);
|
||||
|
||||
const value = useMemo(
|
||||
(): ThemeContextValue => ({
|
||||
theme,
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
}),
|
||||
[theme, themeMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
164
MobileApp/src/theme/colors.ts
Normal file
164
MobileApp/src/theme/colors.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
export interface ColorTokens {
|
||||
// Background
|
||||
backgroundPrimary: string;
|
||||
backgroundSecondary: string;
|
||||
backgroundTertiary: string;
|
||||
backgroundElevated: string;
|
||||
|
||||
// Border
|
||||
borderDefault: string;
|
||||
borderSubtle: string;
|
||||
|
||||
// Text
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
textTertiary: string;
|
||||
textInverse: string;
|
||||
|
||||
// Severity
|
||||
severityCritical: string;
|
||||
severityCriticalBg: string;
|
||||
severityMajor: string;
|
||||
severityMajorBg: string;
|
||||
severityMinor: string;
|
||||
severityMinorBg: string;
|
||||
severityWarning: string;
|
||||
severityWarningBg: string;
|
||||
severityInfo: string;
|
||||
severityInfoBg: string;
|
||||
|
||||
// State
|
||||
stateCreated: string;
|
||||
stateAcknowledged: string;
|
||||
stateResolved: string;
|
||||
stateInvestigating: string;
|
||||
stateMuted: string;
|
||||
|
||||
// On-Call
|
||||
oncallActive: string;
|
||||
oncallActiveBg: string;
|
||||
oncallInactive: string;
|
||||
oncallInactiveBg: string;
|
||||
|
||||
// Action
|
||||
actionPrimary: string;
|
||||
actionPrimaryPressed: string;
|
||||
actionDestructive: string;
|
||||
actionDestructivePressed: string;
|
||||
|
||||
// Status
|
||||
statusSuccess: string;
|
||||
statusSuccessBg: string;
|
||||
statusError: string;
|
||||
statusErrorBg: string;
|
||||
}
|
||||
|
||||
export const darkColors: ColorTokens = {
|
||||
// Background
|
||||
backgroundPrimary: "#0D1117",
|
||||
backgroundSecondary: "#161B22",
|
||||
backgroundTertiary: "#21262D",
|
||||
backgroundElevated: "#1C2128",
|
||||
|
||||
// Border
|
||||
borderDefault: "#30363D",
|
||||
borderSubtle: "#21262D",
|
||||
|
||||
// Text
|
||||
textPrimary: "#E6EDF3",
|
||||
textSecondary: "#8B949E",
|
||||
textTertiary: "#6E7681",
|
||||
textInverse: "#0D1117",
|
||||
|
||||
// Severity
|
||||
severityCritical: "#F85149",
|
||||
severityCriticalBg: "#F8514926",
|
||||
severityMajor: "#F0883E",
|
||||
severityMajorBg: "#F0883E26",
|
||||
severityMinor: "#D29922",
|
||||
severityMinorBg: "#D2992226",
|
||||
severityWarning: "#E3B341",
|
||||
severityWarningBg: "#E3B34126",
|
||||
severityInfo: "#58A6FF",
|
||||
severityInfoBg: "#58A6FF26",
|
||||
|
||||
// State
|
||||
stateCreated: "#F85149",
|
||||
stateAcknowledged: "#D29922",
|
||||
stateResolved: "#3FB950",
|
||||
stateInvestigating: "#F0883E",
|
||||
stateMuted: "#6E7681",
|
||||
|
||||
// On-Call
|
||||
oncallActive: "#3FB950",
|
||||
oncallActiveBg: "#3FB95026",
|
||||
oncallInactive: "#6E7681",
|
||||
oncallInactiveBg: "#6E768126",
|
||||
|
||||
// Action
|
||||
actionPrimary: "#6366F1",
|
||||
actionPrimaryPressed: "#4F46E5",
|
||||
actionDestructive: "#F85149",
|
||||
actionDestructivePressed: "#DA3633",
|
||||
|
||||
// Status
|
||||
statusSuccess: "#3FB950",
|
||||
statusSuccessBg: "#3FB95026",
|
||||
statusError: "#F85149",
|
||||
statusErrorBg: "#F8514926",
|
||||
};
|
||||
|
||||
export const lightColors: ColorTokens = {
|
||||
// Background
|
||||
backgroundPrimary: "#FFFFFF",
|
||||
backgroundSecondary: "#F6F8FA",
|
||||
backgroundTertiary: "#EAEEF2",
|
||||
backgroundElevated: "#FFFFFF",
|
||||
|
||||
// Border
|
||||
borderDefault: "#D0D7DE",
|
||||
borderSubtle: "#EAEEF2",
|
||||
|
||||
// Text
|
||||
textPrimary: "#1F2328",
|
||||
textSecondary: "#656D76",
|
||||
textTertiary: "#8C959F",
|
||||
textInverse: "#FFFFFF",
|
||||
|
||||
// Severity
|
||||
severityCritical: "#CF222E",
|
||||
severityCriticalBg: "#CF222E1A",
|
||||
severityMajor: "#BC4C00",
|
||||
severityMajorBg: "#BC4C001A",
|
||||
severityMinor: "#9A6700",
|
||||
severityMinorBg: "#9A67001A",
|
||||
severityWarning: "#BF8700",
|
||||
severityWarningBg: "#BF87001A",
|
||||
severityInfo: "#0969DA",
|
||||
severityInfoBg: "#0969DA1A",
|
||||
|
||||
// State
|
||||
stateCreated: "#CF222E",
|
||||
stateAcknowledged: "#9A6700",
|
||||
stateResolved: "#1A7F37",
|
||||
stateInvestigating: "#BC4C00",
|
||||
stateMuted: "#8C959F",
|
||||
|
||||
// On-Call
|
||||
oncallActive: "#1A7F37",
|
||||
oncallActiveBg: "#1A7F371A",
|
||||
oncallInactive: "#8C959F",
|
||||
oncallInactiveBg: "#8C959F1A",
|
||||
|
||||
// Action
|
||||
actionPrimary: "#6366F1",
|
||||
actionPrimaryPressed: "#4F46E5",
|
||||
actionDestructive: "#CF222E",
|
||||
actionDestructivePressed: "#A40E26",
|
||||
|
||||
// Status
|
||||
statusSuccess: "#1A7F37",
|
||||
statusSuccessBg: "#1A7F371A",
|
||||
statusError: "#CF222E",
|
||||
statusErrorBg: "#CF222E1A",
|
||||
};
|
||||
6
MobileApp/src/theme/index.ts
Normal file
6
MobileApp/src/theme/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { darkColors, lightColors } from "./colors";
|
||||
export type { ColorTokens } from "./colors";
|
||||
export { typography } from "./typography";
|
||||
export { spacing, radius } from "./spacing";
|
||||
export { ThemeProvider, useTheme } from "./ThemeContext";
|
||||
export type { Theme, ThemeMode } from "./ThemeContext";
|
||||
16
MobileApp/src/theme/spacing.ts
Normal file
16
MobileApp/src/theme/spacing.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const spacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
} as const;
|
||||
|
||||
export const radius = {
|
||||
sm: 6,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
} as const;
|
||||
|
||||
export type Spacing = typeof spacing;
|
||||
export type Radius = typeof radius;
|
||||
91
MobileApp/src/theme/typography.ts
Normal file
91
MobileApp/src/theme/typography.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Platform, TextStyle } from "react-native";
|
||||
|
||||
const fontFamily: string = Platform.OS === "ios" ? "System" : "Roboto";
|
||||
const monoFontFamily: string = Platform.OS === "ios" ? "Menlo" : "monospace";
|
||||
|
||||
interface TypographyStyles {
|
||||
titleLarge: TextStyle;
|
||||
titleMedium: TextStyle;
|
||||
titleSmall: TextStyle;
|
||||
bodyLarge: TextStyle;
|
||||
bodyMedium: TextStyle;
|
||||
bodySmall: TextStyle;
|
||||
monoLarge: TextStyle;
|
||||
monoMedium: TextStyle;
|
||||
monoSmall: TextStyle;
|
||||
label: TextStyle;
|
||||
caption: TextStyle;
|
||||
}
|
||||
|
||||
export const typography: TypographyStyles = {
|
||||
titleLarge: {
|
||||
fontFamily,
|
||||
fontSize: 28,
|
||||
fontWeight: "700",
|
||||
lineHeight: 34,
|
||||
},
|
||||
titleMedium: {
|
||||
fontFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: "600",
|
||||
lineHeight: 28,
|
||||
},
|
||||
titleSmall: {
|
||||
fontFamily,
|
||||
fontSize: 17,
|
||||
fontWeight: "600",
|
||||
lineHeight: 22,
|
||||
},
|
||||
bodyLarge: {
|
||||
fontFamily,
|
||||
fontSize: 17,
|
||||
fontWeight: "400",
|
||||
lineHeight: 24,
|
||||
},
|
||||
bodyMedium: {
|
||||
fontFamily,
|
||||
fontSize: 15,
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
},
|
||||
bodySmall: {
|
||||
fontFamily,
|
||||
fontSize: 13,
|
||||
fontWeight: "400",
|
||||
lineHeight: 18,
|
||||
},
|
||||
monoLarge: {
|
||||
fontFamily: monoFontFamily,
|
||||
fontSize: 17,
|
||||
fontWeight: "400",
|
||||
lineHeight: 24,
|
||||
},
|
||||
monoMedium: {
|
||||
fontFamily: monoFontFamily,
|
||||
fontSize: 15,
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
},
|
||||
monoSmall: {
|
||||
fontFamily: monoFontFamily,
|
||||
fontSize: 13,
|
||||
fontWeight: "400",
|
||||
lineHeight: 18,
|
||||
},
|
||||
label: {
|
||||
fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
caption: {
|
||||
fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: "400",
|
||||
lineHeight: 16,
|
||||
},
|
||||
};
|
||||
|
||||
export default typography;
|
||||
10
MobileApp/tsconfig.json
Normal file
10
MobileApp/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user