Files
ChatApp/packages/shared/src/auth/magic-link.ts
T
byGalax b61f929cf7 fix(shared): drop .js extensions from relative imports for Metro
packages/shared/src/index.ts and all sub-modules used .js extensions on
relative imports (e.g. './admin/index.js') pointing at .ts source files.
TypeScript with moduleResolution: "Bundler" doesn't need them, and
Metro's eager exporter (used for preview / production builds) reads
them literally and fails — only the dev-server Metro fell back to .ts.

Workspace typecheck remains 8/8 green; Vite and TS Bundler resolution
already accept both styles, so desktop is unaffected.
2026-05-15 01:52:14 +02:00

116 lines
3.9 KiB
TypeScript

import type { SupportedLocale } from '../i18n/types';
import type { AppSupabaseClient } from '../supabase/client';
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;
}