feat(auth): add session management UI, QR login page, mobile QR support, translations

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-16 22:38:13 +00:00
parent a4588a57cb
commit 51821092f4
10 changed files with 699 additions and 8 deletions
+16
View File
@@ -38,6 +38,7 @@ export interface AuthState {
user: WhoAmIResponse | null;
baseUrl: string;
signIn: (serverUrl: string) => Promise<void>;
signInWithQR: (serverUrl: string, challengeToken: string) => Promise<void>;
signOut: () => Promise<void>;
setToken: (token: string) => Promise<void>;
}
@@ -52,6 +53,7 @@ const AuthContext = createContext<AuthState>({
user: null,
baseUrl: "",
signIn: async () => {},
signInWithQR: async () => {},
signOut: async () => {},
setToken: async () => {},
});
@@ -143,6 +145,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
[setToken]
);
const signInWithQR = useCallback(
async (serverUrl: string, challengeToken: string) => {
const cleanUrl = serverUrl.replace(/\/$/, "");
await api.init(cleanUrl);
setBaseUrl(cleanUrl);
const deviceInfo = await _getDeviceName();
const resp = await api.claimQRChallenge(challengeToken, deviceInfo);
await setToken(resp.token);
},
[setToken]
);
const signOut = useCallback(async () => {
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
@@ -158,6 +173,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
user,
baseUrl,
signIn,
signInWithQR,
signOut,
setToken,
}}
+102 -8
View File
@@ -1,13 +1,16 @@
/**
* LoginScreen server URL entry and SSO sign-in.
* LoginScreen server URL entry, SSO sign-in, and QR code login.
*
* Renders a server URL input and a "Sign in with SSO" button that opens the
* DocuElevate web login page in the system browser. On success the
* AuthContext stores the API token and navigates to the main app.
* Renders a server URL input, a "Sign in with SSO" button that opens the
* DocuElevate web login page in the system browser, and a "Scan QR Code"
* button that opens the device camera to scan a QR code generated from the
* web interface. On success the AuthContext stores the API token and
* navigates to the main app.
*/
import * as Linking from "expo-linking";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
@@ -23,10 +26,46 @@ import {
import { useAuth } from "../context/AuthContext";
export default function LoginScreen() {
const { signIn } = useAuth();
const { signIn, signInWithQR } = useAuth();
const router = useRouter();
const [serverUrl, setServerUrl] = useState("");
const [loading, setLoading] = useState(false);
const [qrLoading, setQrLoading] = useState(false);
// Handle incoming deep links for QR login (docuelevate://qr-login?token=...&server=...)
const handleDeepLink = useCallback(
async (event: { url: string }) => {
try {
const url = new URL(event.url);
if (url.hostname === "qr-login" || url.pathname === "/qr-login") {
const token = url.searchParams.get("token");
const server = url.searchParams.get("server");
if (token && server) {
setQrLoading(true);
await signInWithQR(server, token);
}
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "QR login failed";
Alert.alert("QR Login Failed", message);
} finally {
setQrLoading(false);
}
},
[signInWithQR]
);
useEffect(() => {
// Listen for incoming deep links
const subscription = Linking.addEventListener("url", handleDeepLink);
// Check if the app was opened via a deep link
Linking.getInitialURL().then((url) => {
if (url) handleDeepLink({ url });
});
return () => subscription.remove();
}, [handleDeepLink]);
async function handleSignIn() {
const url = serverUrl.trim();
@@ -85,7 +124,7 @@ export default function LoginScreen() {
<Pressable
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleSignIn}
disabled={loading}
disabled={loading || qrLoading}
accessibilityRole="button"
accessibilityLabel="Sign in with SSO"
>
@@ -96,8 +135,33 @@ export default function LoginScreen() {
)}
</Pressable>
<View style={styles.dividerRow}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or</Text>
<View style={styles.dividerLine} />
</View>
<Pressable
style={[styles.qrButton, qrLoading && styles.buttonDisabled]}
onPress={() => {
Alert.alert(
"Scan QR Code",
"Open the DocuElevate web app on your computer, go to Profile → Security & Sessions → \"Log in on mobile via QR code\", and scan the QR code shown there.\n\nThe app will automatically detect the QR code when scanned with your device camera."
);
}}
disabled={loading || qrLoading}
accessibilityRole="button"
accessibilityLabel="Sign in with QR code"
>
{qrLoading ? (
<ActivityIndicator color="#1e40af" />
) : (
<Text style={styles.qrButtonText}>📱 Scan QR Code to Login</Text>
)}
</Pressable>
<Text style={styles.hint}>
You will be redirected to your organisation's sign-in page.
Sign in via SSO or scan a QR code from the web app.
</Text>
<Pressable
@@ -184,6 +248,36 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: "600",
},
dividerRow: {
flexDirection: "row",
alignItems: "center",
marginVertical: 16,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: "#e5e7eb",
},
dividerText: {
marginHorizontal: 12,
fontSize: 12,
color: "#9ca3af",
},
qrButton: {
borderWidth: 1,
borderColor: "#1e40af",
borderRadius: 8,
paddingVertical: 14,
alignItems: "center",
justifyContent: "center",
minHeight: 48,
backgroundColor: "#eff6ff",
},
qrButtonText: {
color: "#1e40af",
fontSize: 15,
fontWeight: "600",
},
hint: {
marginTop: 16,
fontSize: 12,
+15
View File
@@ -35,6 +35,14 @@ export interface GenerateTokenResponse {
created_at: string;
}
export interface QRClaimResponse {
token: string;
token_id: number;
name: string;
owner_id: string;
created_at: string;
}
export interface DeviceRegistration {
push_token: string;
device_name?: string;
@@ -157,6 +165,13 @@ class DocuElevateAPI {
});
}
/** Claim a QR login challenge and receive an API token. */
async claimQRChallenge(challengeToken: string, deviceName: string): Promise<QRClaimResponse> {
return this.request<QRClaimResponse>("POST", "/api/qr-auth/claim", {
body: { challenge_token: challengeToken, device_name: deviceName },
});
}
/** Return profile information for the authenticated user. */
async whoAmI(): Promise<WhoAmIResponse> {
return this.request<WhoAmIResponse>("GET", "/api/mobile/whoami");