mobile phase 1

This commit is contained in:
Nawaz Dhandala
2026-02-09 23:45:17 +00:00
parent 4582f6100a
commit 54909116b9
49 changed files with 12636 additions and 62 deletions

View File

@@ -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(

View File

@@ -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],

View File

@@ -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,

View File

@@ -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"],
);

View File

@@ -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()

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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(", "),

View File

@@ -0,0 +1,7 @@
enum PushDeviceType {
Web = "web",
iOS = "ios",
Android = "android",
}
export default PushDeviceType;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
export { default } from "./src/App";

32
MobileApp/app.json Normal file
View 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"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
MobileApp/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

8
MobileApp/index.ts Normal file
View 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

File diff suppressed because it is too large Load Diff

32
MobileApp/package.json Normal file
View 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
View 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
View 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
View 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;

View 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,
},
});

View 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,
},
});

View 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,
},
});

View 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,
},
});

View 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",
},
});

View 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;
}

View 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>
);
}

View 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>
);
}

View 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",
},
});

View File

@@ -0,0 +1,11 @@
export type AuthStackParamList = {
ServerUrl: undefined;
Login: undefined;
};
export type MainTabParamList = {
Home: undefined;
Incidents: undefined;
Alerts: undefined;
Settings: undefined;
};

View 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",
},
});

View 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",
},
});

View 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",
},
});

View 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,
},
});

View 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,
},
});

View 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,
},
});

View 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 });
}

View 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);
}

View 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;
}

View 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",
};

View 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";

View 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;

View 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
View File

@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}