Files
ChatApp/packages/shared/src/auth/magic-link.ts
T
byGalax eb8f9857ff feat: device backup/restore + quick wins + username casing
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)
2026-04-20 19:07:31 +02:00

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