eb8f9857ff
Backup / restore flow: - deviceBackup.ts: v2 format wrapping userId + deviceId + privateKey in an encrypted JSON payload so restore can re-seed localStorage, vault, and reattach to the existing server-side device row without provisioning a new one (conv-key bundles stay valid, no "awaiting key" state) - shared/auth: restoreDeviceFromServerRecord — verifies session.user.id matches the backup's userId, confirms the server device row still exists, then writes the private key into the local secret store - BackupExportDialog — passphrase + confirm, generates portable string, copy + download .txt - DeviceRestore — textarea + passphrase → seeds vault + writes deviceId cache, treats this install as the original device - DevicePage now has tabs "Neu einrichten" / "Backup wiederherstellen" - BackupPromptBanner — post-registration nudge, reads sessionStorage signal from fresh provisions and persists "never-ask-again" in localStorage so it stops nagging - SettingsPage backup section: uses the new dialog; removes the dangerous in-place key import (restore now lives in the device flow) Username casing: - Migration 20260420000002 drops lower() from the handle_new_user trigger and widens the regex to [A-Za-z0-9_]. profiles.username is citext so uniqueness + lookups stay case-insensitive regardless of stored casing - Shared auth: trim() only, no toLowerCase on signup/lookups/search. ilike handles CI anyway and citext makes client normalisation redundant - AuthPage regex + input preserve case, FriendsPage search preserves case - i18n (de+en): updated username_hint / username_invalid / ERR_USERNAME_INVALID to reflect the new rule Quick wins: - React Router v7 future flags (v7_startTransition + v7_relativeSplatPath) set on BrowserRouter — silences the upgrade warning - appUpdates.checkForUpdate: swallow benign network/fetch/"could not fetch valid release JSON" cases silently instead of console spam - osNotify: persist an "asked" marker in localStorage so the permission prompt only fires once per install (OS already persists the answer, but the plugin re-queries loudly otherwise)
116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
import type { SupportedLocale } from '../i18n/types.js';
|
|
import type { AppSupabaseClient } from '../supabase/client.js';
|
|
|
|
export interface SignupParams {
|
|
email: string;
|
|
// Login handle. Stored with original casing but uniqueness is case-insensitive
|
|
// (citext). Must match ^[A-Za-z0-9_]{3,32}$.
|
|
username: string;
|
|
// Optional human-readable name. Defaults to username server-side if omitted.
|
|
displayName?: string;
|
|
// Invite code is mandatory (enforced by the handle_new_user trigger).
|
|
inviteCode: string;
|
|
// Deep link the magic-link email should bounce back to.
|
|
// e.g. `chatapp://auth/callback` on mobile, `http://localhost:1420/auth/callback` on desktop dev.
|
|
redirectTo: string;
|
|
// Optional initial locale for the profile row. Defaults to 'en' server-side.
|
|
locale?: SupportedLocale;
|
|
}
|
|
|
|
// Trigger signup. User receives a magic link email. Completion happens in
|
|
// `completeSessionFromUrl` below once the callback fires.
|
|
export async function signUpWithMagicLink(
|
|
client: AppSupabaseClient,
|
|
params: SignupParams,
|
|
): Promise<void> {
|
|
const { error } = await client.auth.signInWithOtp({
|
|
email: params.email,
|
|
options: {
|
|
emailRedirectTo: params.redirectTo,
|
|
shouldCreateUser: true,
|
|
data: {
|
|
invite_code: params.inviteCode,
|
|
// Preserve case. `profiles.username` is citext so uniqueness + lookups
|
|
// stay case-insensitive regardless of stored casing.
|
|
username: params.username.trim(),
|
|
display_name: params.displayName ?? params.username,
|
|
...(params.locale ? { locale: params.locale } : {}),
|
|
},
|
|
},
|
|
});
|
|
if (error) throw error;
|
|
}
|
|
|
|
// Trigger magic-link login for an existing user. No invite code needed.
|
|
export async function loginWithMagicLink(
|
|
client: AppSupabaseClient,
|
|
email: string,
|
|
redirectTo: string,
|
|
): Promise<void> {
|
|
const { error } = await client.auth.signInWithOtp({
|
|
email,
|
|
options: {
|
|
emailRedirectTo: redirectTo,
|
|
shouldCreateUser: false,
|
|
},
|
|
});
|
|
if (error) throw error;
|
|
}
|
|
|
|
// Parse a callback URL produced by the magic-link email and establish a session.
|
|
// Works for both PKCE (`?code=…`) and legacy token-hash (`#access_token=…`).
|
|
export async function completeSessionFromUrl(
|
|
client: AppSupabaseClient,
|
|
url: string,
|
|
): Promise<void> {
|
|
const u = new URL(url);
|
|
|
|
// PKCE flow: exchange the code.
|
|
const code = u.searchParams.get('code');
|
|
if (code) {
|
|
const { error } = await client.auth.exchangeCodeForSession(code);
|
|
if (error) throw error;
|
|
return;
|
|
}
|
|
|
|
// Hash fragment flow.
|
|
if (u.hash && u.hash.length > 1) {
|
|
const hash = new URLSearchParams(u.hash.slice(1));
|
|
const access_token = hash.get('access_token');
|
|
const refresh_token = hash.get('refresh_token');
|
|
if (access_token && refresh_token) {
|
|
const { error } = await client.auth.setSession({ access_token, refresh_token });
|
|
if (error) throw error;
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw new Error('callback URL contained no auth params');
|
|
}
|
|
|
|
export async function signOut(client: AppSupabaseClient): Promise<void> {
|
|
// `scope: 'local'` only ends the session in THIS client. Without it Supabase
|
|
// defaults to 'global', which invalidates the user's refresh tokens
|
|
// everywhere — meaning a logout in the browser would also kick the desktop
|
|
// app (and vice versa) the next time it tries to refresh its token.
|
|
const { error } = await client.auth.signOut({ scope: 'local' });
|
|
if (error) throw error;
|
|
}
|
|
|
|
// Verify a 6-digit OTP that arrived via magic-link email. Establishes a
|
|
// session in the CURRENT webview — no browser redirect involved. Used by the
|
|
// desktop/mobile apps where cross-origin redirects don't carry the session
|
|
// back to the native webview.
|
|
export async function verifyMagicLinkOtp(
|
|
client: AppSupabaseClient,
|
|
email: string,
|
|
token: string,
|
|
): Promise<void> {
|
|
const { error } = await client.auth.verifyOtp({
|
|
email,
|
|
token: token.trim(),
|
|
type: 'email',
|
|
});
|
|
if (error) throw error;
|
|
}
|