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)
This commit is contained in:
@@ -36,7 +36,19 @@ export function App() {
|
|||||||
<FriendshipsProvider>
|
<FriendshipsProvider>
|
||||||
<ConversationsProvider>
|
<ConversationsProvider>
|
||||||
<CallProvider>
|
<CallProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter
|
||||||
|
future={{
|
||||||
|
// Opt into v7 behaviour early so the upgrade is a no-op:
|
||||||
|
// - `v7_startTransition` wraps navigations in startTransition
|
||||||
|
// so Suspense / concurrent rendering deal with the new tree
|
||||||
|
// - `v7_relativeSplatPath` matches relative paths inside
|
||||||
|
// splat routes against the parent splat segment (not the
|
||||||
|
// full matched path). Our tree has no splat routes today
|
||||||
|
// but this kills the runtime warning and future-proofs.
|
||||||
|
v7_startTransition: true,
|
||||||
|
v7_relativeSplatPath: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<RouteBoundary scope="auth" />}>
|
<Route element={<RouteBoundary scope="auth" />}>
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||||
|
import { BackupPromptBanner } from './BackupPromptBanner';
|
||||||
import { CallUI } from './CallUI';
|
import { CallUI } from './CallUI';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export function AppShell() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<CallUI />
|
<CallUI />
|
||||||
|
<BackupPromptBanner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { exportDeviceBackup } from '../lib/deviceBackup';
|
||||||
|
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exports the device's private key + identity into a passphrase-protected
|
||||||
|
// portable string. The user can store this string anywhere (password manager,
|
||||||
|
// printed paper, encrypted file on a USB stick). Without it, losing local
|
||||||
|
// storage on this install means losing all past conversation keys.
|
||||||
|
export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [passphrase, setPassphrase] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [backup, setBackup] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const canGenerate = useMemo(() => {
|
||||||
|
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
||||||
|
}, [passphrase, confirm, busy]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setPassphrase('');
|
||||||
|
setConfirm('');
|
||||||
|
setBackup(null);
|
||||||
|
setError(null);
|
||||||
|
setCopied(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}, [reset, onClose]);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (!canGenerate) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const str = await exportDeviceBackup({ userId, deviceId, privateKey, passphrase });
|
||||||
|
setBackup(str);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
if (!backup) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(backup);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* fall back — user can select manually */
|
||||||
|
}
|
||||||
|
}, [backup]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!backup) return;
|
||||||
|
const blob = new Blob([backup], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [backup, deviceId]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldIcon className="h-4 w-4 text-accent" />
|
||||||
|
<h3 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{!backup ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('app:backup.export_explainer', {
|
||||||
|
defaultValue:
|
||||||
|
'Verschlüssele den Geräteschlüssel mit einer Passphrase. Ohne Passphrase UND Backup-String ist keine Wiederherstellung möglich.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
minLength={8}
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
placeholder="min. 8 Zeichen"
|
||||||
|
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
{t('app:backup.passphrase_confirm', { defaultValue: 'Passphrase wiederholen' })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{confirm.length > 0 && confirm !== passphrase && (
|
||||||
|
<p className="text-xs text-rose-500 dark:text-rose-300">
|
||||||
|
{t('app:backup.passphrase_mismatch', { defaultValue: 'Passphrasen stimmen nicht überein.' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||||
|
<span>
|
||||||
|
{t('app:backup.export_warning', {
|
||||||
|
defaultValue:
|
||||||
|
'Anthropic: Backup + Passphrase sicher aufbewahren. Passphrase kann nicht wiederhergestellt werden.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-rose-600 dark:text-rose-200">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<LockIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{t('app:backup.export_success', {
|
||||||
|
defaultValue:
|
||||||
|
'Backup erstellt. Speichere diesen String + Passphrase in einem Passwortmanager oder drucke ihn aus.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={backup}
|
||||||
|
rows={8}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="mt-3 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{copied
|
||||||
|
? t('app:backup.copied', { defaultValue: 'Kopiert!' })
|
||||||
|
: t('app:backup.copy', { defaultValue: 'Kopieren' })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{t('app:backup.download', { defaultValue: 'Als Datei speichern' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||||
|
{!backup ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleGenerate()}
|
||||||
|
disabled={!canGenerate}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||||
|
<span>{t('app:backup.generate', { defaultValue: 'Backup erstellen' })}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110"
|
||||||
|
>
|
||||||
|
{t('app:backup.done', { defaultValue: 'Fertig' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
|
import { BackupExportDialog } from './BackupExportDialog';
|
||||||
|
import { ShieldIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
const DISMISS_KEY = 'chatapp.backup.prompt.dismissed';
|
||||||
|
const SESSION_KEY = 'chatapp.backup.prompt';
|
||||||
|
|
||||||
|
// Post-registration nudge: right after a fresh device provision we set
|
||||||
|
// `chatapp.backup.prompt` in sessionStorage. This component reads it and
|
||||||
|
// shows a floating "mach jetzt ein Backup" banner until the user either
|
||||||
|
// creates one or explicitly dismisses (persisted in localStorage so we stop
|
||||||
|
// nagging across reloads).
|
||||||
|
export function BackupPromptBanner() {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const { profile, device } = useAuth();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (window.localStorage.getItem(DISMISS_KEY) === '1') return;
|
||||||
|
if (window.sessionStorage.getItem(SESSION_KEY) !== '1') return;
|
||||||
|
setVisible(true);
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = useCallback((persist: boolean) => {
|
||||||
|
setVisible(false);
|
||||||
|
try {
|
||||||
|
window.sessionStorage.removeItem(SESSION_KEY);
|
||||||
|
if (persist) window.localStorage.setItem(DISMISS_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openDialog = useCallback(async () => {
|
||||||
|
if (!profile?.userId || !device?.id) return;
|
||||||
|
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||||
|
if (!priv) return;
|
||||||
|
setPrivateKey(priv);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, [profile, device]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
if (privateKey) {
|
||||||
|
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||||
|
}
|
||||||
|
setPrivateKey(null);
|
||||||
|
// After the user interacts with the dialog, drop the banner regardless
|
||||||
|
// of whether they actually completed the backup — they're aware now.
|
||||||
|
dismiss(true);
|
||||||
|
}, [privateKey, dismiss]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="fixed bottom-6 left-1/2 z-40 flex w-[min(92vw,520px)] -translate-x-1/2 items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-800 shadow-xl backdrop-blur-md dark:text-amber-100"
|
||||||
|
>
|
||||||
|
<ShieldIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-semibold">
|
||||||
|
{t('app:backup.prompt_title', { defaultValue: 'Erstelle jetzt ein Geräte-Backup' })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-amber-700/90 dark:text-amber-200/90">
|
||||||
|
{t('app:backup.prompt_body', {
|
||||||
|
defaultValue:
|
||||||
|
'Ohne Backup verlierst du Zugriff auf alte Nachrichten, wenn Browser oder Gerät ihren Speicher verlieren. Dauert 10 Sekunden.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openDialog()}
|
||||||
|
className="cursor-pointer rounded-md bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-500"
|
||||||
|
>
|
||||||
|
{t('app:backup.prompt_create', { defaultValue: 'Jetzt erstellen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(true)}
|
||||||
|
className="cursor-pointer rounded-md border border-amber-500/40 bg-transparent px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/15 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
{t('app:backup.prompt_never', { defaultValue: 'Nicht mehr fragen' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(false)}
|
||||||
|
aria-label={t('app:backup.prompt_dismiss', { defaultValue: 'Später' })}
|
||||||
|
className="cursor-pointer text-amber-600/70 transition hover:text-amber-600 dark:text-amber-200/70 dark:hover:text-amber-200"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dialogOpen && profile?.userId && device?.id && privateKey && (
|
||||||
|
<BackupExportDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
userId={profile.userId}
|
||||||
|
deviceId={device.id}
|
||||||
|
privateKey={privateKey}
|
||||||
|
onClose={closeDialog}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
type DeviceRecord,
|
||||||
|
restoreDeviceFromServerRecord,
|
||||||
|
} from '@chat-app/shared/auth';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decodePrivateKeyFromBackup,
|
||||||
|
importDeviceBackup,
|
||||||
|
} from '../lib/deviceBackup';
|
||||||
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { writeLocalDeviceId } from '../lib/device';
|
||||||
|
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
onRestored: (device: DeviceRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restores a device from a user-provided backup string. The backup embeds
|
||||||
|
// userId + deviceId + X25519 private key; we verify userId matches the current
|
||||||
|
// session, confirm the device row still exists server-side, and then re-seed
|
||||||
|
// the local vault + cached deviceId so the app treats this install as the
|
||||||
|
// original device (conv-key bundles stay valid, no "awaiting" state).
|
||||||
|
export function DeviceRestore({ userId, onRestored }: Props) {
|
||||||
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
|
const [backup, setBackup] = useState('');
|
||||||
|
const [passphrase, setPassphrase] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!backup.trim() || passphrase.length < 1 || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
let privateKey: Uint8Array | null = null;
|
||||||
|
try {
|
||||||
|
const payload = await importDeviceBackup(backup.trim(), passphrase);
|
||||||
|
privateKey = decodePrivateKeyFromBackup(payload);
|
||||||
|
|
||||||
|
const device = await restoreDeviceFromServerRecord({
|
||||||
|
client: supabase,
|
||||||
|
secretStore: devLocalSecretStore,
|
||||||
|
userId: payload.userId,
|
||||||
|
deviceId: payload.deviceId,
|
||||||
|
privateKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache deviceId locally so findExistingDevice picks it up on next load.
|
||||||
|
writeLocalDeviceId(userId, device.id);
|
||||||
|
|
||||||
|
onRestored(device);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
if (privateKey) {
|
||||||
|
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[backup, passphrase, userId, onRestored, busy],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/20 ring-1 ring-emerald-400/30">
|
||||||
|
<ShieldIcon className="h-5 w-5 text-emerald-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-lg font-semibold text-white">
|
||||||
|
{t('app:backup.restore_title', { defaultValue: 'Backup wiederherstellen' })}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-400">
|
||||||
|
{t('app:backup.restore_subtitle', {
|
||||||
|
defaultValue: 'Bringe einen zuvor erstellten Backup-String + Passphrase mit.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
{t('app:backup.backup_string', { defaultValue: 'Backup-String' })}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
value={backup}
|
||||||
|
onChange={(e) => setBackup(e.target.value)}
|
||||||
|
placeholder="chatapp-backup-v1…"
|
||||||
|
className="w-full resize-none rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 font-mono text-[11px] leading-relaxed text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-lg border border-brand-500/20 bg-brand-500/10 p-3 text-xs text-brand-100">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<LockIcon className="mt-0.5 h-4 w-4 shrink-0 text-brand-300" />
|
||||||
|
<span className="min-w-0 flex-1 break-words">
|
||||||
|
{t('app:backup.restore_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Nach Wiederherstellung übernimmt dieses Gerät die alte Identität — existierende Nachrichten sind wieder entschlüsselbar.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || backup.trim().length === 0 || passphrase.length === 0}
|
||||||
|
aria-busy={busy}
|
||||||
|
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-emerald-300 hover:to-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
|
<span>{t('app:backup.restoring', { defaultValue: 'Wiederherstellen…' })}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{t('app:backup.restore_cta', { defaultValue: 'Gerät wiederherstellen' })}</span>
|
||||||
|
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||||
|
>
|
||||||
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||||
|
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,10 +44,22 @@ export async function checkForUpdate(): Promise<UpdateState> {
|
|||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.warn('checkForUpdate failed', err);
|
// Swallow "no release on GitHub yet" / network-unreachable cases silently.
|
||||||
|
// The updater endpoint serves `latest.json` from GitHub releases; a fresh
|
||||||
|
// repo or offline machine produces a generic "Could not fetch a valid
|
||||||
|
// release JSON" error that has no actionable information for the user —
|
||||||
|
// logging it on every launch just pollutes the console.
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
const benign =
|
||||||
|
/could not fetch a valid release json|network|timed? out|failed to fetch|connection/i.test(
|
||||||
|
msg,
|
||||||
|
);
|
||||||
|
if (!benign) {
|
||||||
|
console.warn('checkForUpdate failed', err);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...IDLE_UPDATE_STATE,
|
...IDLE_UPDATE_STATE,
|
||||||
error: err instanceof Error ? err.message : 'update check failed',
|
error: benign ? null : msg,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function readLocalDeviceId(userId: string): string | null {
|
|||||||
return window.localStorage.getItem(deviceIdStorageKey(userId));
|
return window.localStorage.getItem(deviceIdStorageKey(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLocalDeviceId(userId: string, deviceId: string): void {
|
export function writeLocalDeviceId(userId: string, deviceId: string): void {
|
||||||
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
|
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,3 +91,52 @@ export async function importDeviceKey(
|
|||||||
s.memzero(key);
|
s.memzero(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full-device backup. Wraps userId + deviceId + privateKey in a JSON payload
|
||||||
|
// before encrypting, so a restore flow can re-seed localStorage + vault + server
|
||||||
|
// device row without requiring the user to remember IDs.
|
||||||
|
export interface DeviceBackupPayload {
|
||||||
|
v: 2;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
privateKeyB64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportDeviceBackup(
|
||||||
|
params: {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
passphrase: string;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
const payload: DeviceBackupPayload = {
|
||||||
|
v: 2,
|
||||||
|
userId: params.userId,
|
||||||
|
deviceId: params.deviceId,
|
||||||
|
privateKeyB64: b64url(params.privateKey),
|
||||||
|
};
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
||||||
|
return exportDeviceKey(bytes, params.passphrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importDeviceBackup(
|
||||||
|
backup: string,
|
||||||
|
passphrase: string,
|
||||||
|
): Promise<DeviceBackupPayload> {
|
||||||
|
const plain = await importDeviceKey(backup, passphrase);
|
||||||
|
const text = new TextDecoder().decode(plain);
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(text) as DeviceBackupPayload;
|
||||||
|
if (obj && obj.v === 2 && obj.userId && obj.deviceId && obj.privateKeyB64) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
throw new Error('Backup format not supported — v2 expected');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
|
||||||
|
return unb64url(payload.privateKeyB64);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,10 +7,31 @@ import {
|
|||||||
import { isTauriRuntime } from './globalShortcut';
|
import { isTauriRuntime } from './globalShortcut';
|
||||||
|
|
||||||
// Tracks whether permission has already been requested this session so we
|
// Tracks whether permission has already been requested this session so we
|
||||||
// don't spam the OS prompt. Actual permission state lives in the OS.
|
// don't spam the OS prompt. Actual permission state lives in the OS, but we
|
||||||
|
// also persist a "we've asked" marker in localStorage so reloads don't
|
||||||
|
// re-request (OS would block anyway after denial, but calling it every reload
|
||||||
|
// triggers noisy plugin warnings on some platforms).
|
||||||
let permissionChecked = false;
|
let permissionChecked = false;
|
||||||
let permissionGranted = false;
|
let permissionGranted = false;
|
||||||
|
|
||||||
|
const ASKED_KEY = 'chatapp.notif.asked';
|
||||||
|
|
||||||
|
function readAskedMarker(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(ASKED_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAskedMarker(): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(ASKED_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
/* quota / private mode */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureNotificationPermission(): Promise<boolean> {
|
export async function ensureNotificationPermission(): Promise<boolean> {
|
||||||
if (permissionChecked) return permissionGranted;
|
if (permissionChecked) return permissionGranted;
|
||||||
permissionChecked = true;
|
permissionChecked = true;
|
||||||
@@ -21,9 +42,13 @@ export async function ensureNotificationPermission(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
let granted = await isPermissionGranted();
|
let granted = await isPermissionGranted();
|
||||||
if (!granted) {
|
if (!granted && !readAskedMarker()) {
|
||||||
|
// First-install: prompt the user once. After this we remember via the
|
||||||
|
// marker and never re-prompt — the user can re-enable later via OS
|
||||||
|
// system settings if they change their mind.
|
||||||
const result = await requestPermission();
|
const result = await requestPermission();
|
||||||
granted = result === 'granted';
|
granted = result === 'granted';
|
||||||
|
writeAskedMarker();
|
||||||
}
|
}
|
||||||
permissionGranted = granted;
|
permissionGranted = granted;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ type UiState =
|
|||||||
| { kind: 'sent'; email: string }
|
| { kind: 'sent'; email: string }
|
||||||
| { kind: 'error'; message: string };
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
const USERNAME_PATTERN = /^[a-z0-9_]{3,32}$/;
|
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,32}$/;
|
||||||
|
|
||||||
export function AuthPage() {
|
export function AuthPage() {
|
||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
@@ -314,7 +314,7 @@ function FormCard({
|
|||||||
required
|
required
|
||||||
placeholder={t('auth:fields.username_placeholder')}
|
placeholder={t('auth:fields.username_placeholder')}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => onUsernameChange(e.target.value.toLowerCase())}
|
onChange={(e) => onUsernameChange(e.target.value)}
|
||||||
pattern={USERNAME_PATTERN.source}
|
pattern={USERNAME_PATTERN.source}
|
||||||
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { DeviceRegistration } from '../components/DeviceRegistration';
|
import { DeviceRegistration } from '../components/DeviceRegistration';
|
||||||
|
import { DeviceRestore } from '../components/DeviceRestore';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
type Mode = 'register' | 'restore';
|
||||||
|
|
||||||
export function DevicePage() {
|
export function DevicePage() {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
const { session, profile, setDevice } = useAuth();
|
const { session, profile, setDevice } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [mode, setMode] = useState<Mode>('register');
|
||||||
|
|
||||||
if (!session) return null; // Guarded by RequireAuth, but be defensive.
|
if (!session) return null; // Guarded by RequireAuth, but be defensive.
|
||||||
|
|
||||||
@@ -15,20 +22,81 @@ export function DevicePage() {
|
|||||||
return (
|
return (
|
||||||
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8">
|
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8">
|
||||||
<BackgroundStage />
|
<BackgroundStage />
|
||||||
<div className="relative z-10 w-full max-w-md">
|
<div className="relative z-10 w-full max-w-md space-y-3">
|
||||||
<DeviceRegistration
|
<div
|
||||||
userId={session.user.id}
|
role="tablist"
|
||||||
defaultName={defaultName}
|
aria-label={t('app:backup.mode_label', { defaultValue: 'Gerätemodus' })}
|
||||||
onRegistered={(device) => {
|
className="flex gap-1 rounded-xl border border-white/10 bg-ink-900/60 p-1 backdrop-blur-xl"
|
||||||
setDevice(device);
|
>
|
||||||
navigate('/chats', { replace: true });
|
<TabButton
|
||||||
}}
|
active={mode === 'register'}
|
||||||
/>
|
onClick={() => setMode('register')}
|
||||||
|
label={t('app:backup.mode_register', { defaultValue: 'Neu einrichten' })}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={mode === 'restore'}
|
||||||
|
onClick={() => setMode('restore')}
|
||||||
|
label={t('app:backup.mode_restore', { defaultValue: 'Backup wiederherstellen' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'register' ? (
|
||||||
|
<DeviceRegistration
|
||||||
|
userId={session.user.id}
|
||||||
|
defaultName={defaultName}
|
||||||
|
onRegistered={(device) => {
|
||||||
|
setDevice(device);
|
||||||
|
// Signal the chats page to open the post-registration backup
|
||||||
|
// prompt (AppShell reads this on mount).
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem('chatapp.backup.prompt', '1');
|
||||||
|
} catch {
|
||||||
|
/* storage disabled — skip hint */
|
||||||
|
}
|
||||||
|
navigate('/chats', { replace: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DeviceRestore
|
||||||
|
userId={session.user.id}
|
||||||
|
onRestored={(device) => {
|
||||||
|
setDevice(device);
|
||||||
|
navigate('/chats', { replace: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={onClick}
|
||||||
|
className={
|
||||||
|
'flex-1 cursor-pointer rounded-lg px-3 py-2 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||||
|
(active
|
||||||
|
? 'bg-brand-500/20 text-brand-100 ring-1 ring-brand-400/40'
|
||||||
|
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BackgroundStage() {
|
function BackgroundStage() {
|
||||||
return (
|
return (
|
||||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function FriendsPage() {
|
|||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value.toLowerCase())}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder={t('app:friends.search_placeholder')}
|
placeholder={t('app:friends.search_placeholder')}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Avatar } from '../components/Avatar';
|
import { Avatar } from '../components/Avatar';
|
||||||
|
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||||
import { LockIcon } from '../components/icons';
|
import { LockIcon } from '../components/icons';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth';
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
|
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
|
||||||
import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup';
|
|
||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import {
|
import {
|
||||||
getPttSettings,
|
getPttSettings,
|
||||||
@@ -447,65 +447,32 @@ function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
|
|||||||
function DeviceKeyBackupControls() {
|
function DeviceKeyBackupControls() {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const { profile, device } = useAuth();
|
const { profile, device } = useAuth();
|
||||||
const [busy, setBusy] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [backupOut, setBackupOut] = useState<string | null>(null);
|
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||||
const [exportPass, setExportPass] = useState('');
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [importPass, setImportPass] = useState('');
|
|
||||||
const [importBlob, setImportBlob] = useState('');
|
|
||||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
|
||||||
|
|
||||||
const canRun = !!profile?.userId && !!device?.id;
|
const canRun = !!profile?.userId && !!device?.id;
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleOpen() {
|
||||||
if (!canRun) return;
|
if (!canRun) return;
|
||||||
setMsg(null);
|
setErr(null);
|
||||||
setBusy(true);
|
|
||||||
try {
|
try {
|
||||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||||
if (!priv) throw new Error('No device key on this install');
|
if (!priv) throw new Error(t('app:backup.no_key_here', {
|
||||||
const out = await exportDeviceKey(priv, exportPass);
|
defaultValue: 'Kein Geräteschlüssel auf dieser Installation.',
|
||||||
setBackupOut(out);
|
}));
|
||||||
setExportPass('');
|
setPrivateKey(priv);
|
||||||
setMsg({
|
setOpen(true);
|
||||||
kind: 'ok',
|
} catch (e: unknown) {
|
||||||
text: t('app:settings.backup_export_ok', {
|
setErr(e instanceof Error ? e.message : 'failed to load key');
|
||||||
defaultValue: 'Backup erstellt — kopiere und bewahre es sicher auf.',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setMsg({
|
|
||||||
kind: 'err',
|
|
||||||
text: err instanceof Error ? err.message : 'export failed',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImport() {
|
function handleClose() {
|
||||||
if (!canRun) return;
|
setOpen(false);
|
||||||
setMsg(null);
|
if (privateKey) {
|
||||||
setBusy(true);
|
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||||
try {
|
|
||||||
const priv = await importDeviceKey(importBlob.trim(), importPass);
|
|
||||||
await saveDevicePrivateKey(devLocalSecretStore, profile.userId, device.id, priv);
|
|
||||||
setImportBlob('');
|
|
||||||
setImportPass('');
|
|
||||||
setMsg({
|
|
||||||
kind: 'ok',
|
|
||||||
text: t('app:settings.backup_import_ok', {
|
|
||||||
defaultValue:
|
|
||||||
'Schlüssel importiert. Beim nächsten Reload sollten alte Nachrichten lesbar sein.',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setMsg({
|
|
||||||
kind: 'err',
|
|
||||||
text: err instanceof Error ? err.message : 'import failed',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
}
|
||||||
|
setPrivateKey(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -514,87 +481,31 @@ function DeviceKeyBackupControls() {
|
|||||||
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
|
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-fg-muted">
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
{t('app:settings.device_key_backup_hint', {
|
{t('app:settings.device_key_backup_hint_v2', {
|
||||||
defaultValue:
|
defaultValue:
|
||||||
'Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.',
|
'Backup deines Geräteschlüssels. Ohne Backup kein Zugriff auf alte Nachrichten wenn Gerät oder Browser-Storage verloren geht. Wiederherstellung passiert im Gerät-Einrichtungsbildschirm.',
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canRun}
|
||||||
|
onClick={() => void handleOpen()}
|
||||||
|
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
|
||||||
|
</button>
|
||||||
|
{err && (
|
||||||
|
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 space-y-2">
|
{open && profile && device && privateKey && (
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
<BackupExportDialog
|
||||||
{t('app:settings.backup_export', { defaultValue: 'Export' })}
|
open={open}
|
||||||
</div>
|
userId={profile.userId}
|
||||||
<div className="flex gap-2">
|
deviceId={device.id}
|
||||||
<input
|
privateKey={privateKey}
|
||||||
type="password"
|
onClose={handleClose}
|
||||||
value={exportPass}
|
|
||||||
onChange={(e) => setExportPass(e.target.value)}
|
|
||||||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase (min 8)' })}
|
|
||||||
className="flex-1 rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || exportPass.length < 8 || !canRun}
|
|
||||||
onClick={() => void handleExport()}
|
|
||||||
className="cursor-pointer rounded-lg bg-accent px-4 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('app:settings.backup_create', { defaultValue: 'Erstellen' })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{backupOut && (
|
|
||||||
<textarea
|
|
||||||
readOnly
|
|
||||||
value={backupOut}
|
|
||||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
|
||||||
rows={3}
|
|
||||||
className="w-full rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2 font-mono text-[10px] text-emerald-700 dark:text-emerald-200"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-2 border-t border-line pt-4">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
|
||||||
{t('app:settings.backup_import', { defaultValue: 'Import' })}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={importBlob}
|
|
||||||
onChange={(e) => setImportBlob(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
placeholder={t('app:settings.backup_blob_placeholder', {
|
|
||||||
defaultValue: 'chatapp-backup-v1.…',
|
|
||||||
})}
|
|
||||||
className="w-full rounded-lg border border-line bg-surface-3 p-2 font-mono text-[11px] text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={importPass}
|
|
||||||
onChange={(e) => setImportPass(e.target.value)}
|
|
||||||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase' })}
|
|
||||||
className="flex-1 rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || !importBlob || !importPass || !canRun}
|
|
||||||
onClick={() => void handleImport()}
|
|
||||||
className="cursor-pointer rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('app:settings.backup_restore', { defaultValue: 'Wiederherstellen' })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{msg && (
|
|
||||||
<p
|
|
||||||
className={
|
|
||||||
'mt-3 text-xs ' +
|
|
||||||
(msg.kind === 'ok'
|
|
||||||
? 'text-emerald-600 dark:text-emerald-300'
|
|
||||||
: 'text-rose-600 dark:text-rose-300')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{msg.text}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -152,6 +152,56 @@ export async function saveDevicePrivateKey(
|
|||||||
await secretStore.setSecret(privateKeySecretName(userId, deviceId), privateKey);
|
await secretStore.setSecret(privateKeySecretName(userId, deviceId), privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restores a device record from a backup by re-seeding the local private key
|
||||||
|
// store for an EXISTING server-side device row. Does NOT insert a new row —
|
||||||
|
// the original row is kept intact so conversation-key bundles stay valid.
|
||||||
|
// Throws when the server-side device was removed (the backup is then unusable;
|
||||||
|
// user must provision a fresh device and get conv-keys shared from another
|
||||||
|
// live device).
|
||||||
|
export async function restoreDeviceFromServerRecord(params: {
|
||||||
|
client: AppSupabaseClient;
|
||||||
|
secretStore: SecretStore;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
}): Promise<DeviceRecord> {
|
||||||
|
const { data: session } = await params.client.auth.getUser();
|
||||||
|
if (!session.user) throw new Error('not authenticated');
|
||||||
|
if (session.user.id !== params.userId) {
|
||||||
|
throw new Error(
|
||||||
|
'Backup is for a different account — sign in as the owner before restoring.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: row, error } = await params.client
|
||||||
|
.from('devices')
|
||||||
|
.select('id, name, platform, public_key, last_seen_at')
|
||||||
|
.eq('id', params.deviceId)
|
||||||
|
.eq('user_id', params.userId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
if (!row) {
|
||||||
|
throw new Error(
|
||||||
|
'Device record not found on server — it was removed. Backup is no longer valid; register a new device instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveDevicePrivateKey(
|
||||||
|
params.secretStore,
|
||||||
|
params.userId,
|
||||||
|
params.deviceId,
|
||||||
|
params.privateKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
platform: row.platform,
|
||||||
|
publicKey: pgHexToBytes(row.public_key),
|
||||||
|
lastSeenAt: row.last_seen_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Lightweight helpers for platforms that want to cache their current device id
|
// Lightweight helpers for platforms that want to cache their current device id
|
||||||
// in JSON storage (separate from the secret store, which only holds raw bytes).
|
// in JSON storage (separate from the secret store, which only holds raw bytes).
|
||||||
export const DEVICE_ID_STORAGE_KEY_PREFIX = 'chatapp.device_id';
|
export const DEVICE_ID_STORAGE_KEY_PREFIX = 'chatapp.device_id';
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import type { AppSupabaseClient } from '../supabase/client.js';
|
|||||||
|
|
||||||
export interface SignupParams {
|
export interface SignupParams {
|
||||||
email: string;
|
email: string;
|
||||||
// Login handle. Lowercased server-side; must match ^[a-z0-9_]{3,32}$ once lowercased.
|
// Login handle. Stored with original casing but uniqueness is case-insensitive
|
||||||
|
// (citext). Must match ^[A-Za-z0-9_]{3,32}$.
|
||||||
username: string;
|
username: string;
|
||||||
// Optional human-readable name. Defaults to username server-side if omitted.
|
// Optional human-readable name. Defaults to username server-side if omitted.
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
@@ -29,7 +30,9 @@ export async function signUpWithMagicLink(
|
|||||||
shouldCreateUser: true,
|
shouldCreateUser: true,
|
||||||
data: {
|
data: {
|
||||||
invite_code: params.inviteCode,
|
invite_code: params.inviteCode,
|
||||||
username: params.username.toLowerCase(),
|
// 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,
|
display_name: params.displayName ?? params.username,
|
||||||
...(params.locale ? { locale: params.locale } : {}),
|
...(params.locale ? { locale: params.locale } : {}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ export async function getProfileByUsername(
|
|||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select(PROFILE_COLS)
|
.select(PROFILE_COLS)
|
||||||
.eq('username', username.toLowerCase())
|
// citext column compares CI server-side — send raw input, don't force case.
|
||||||
|
.eq('username', username.trim())
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data ? mapProfile(data as unknown as ProfileRow) : null;
|
return data ? mapProfile(data as unknown as ProfileRow) : null;
|
||||||
@@ -85,7 +86,8 @@ export async function isUsernameAvailable(
|
|||||||
const { count, error } = await client
|
const { count, error } = await client
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('user_id', { count: 'exact', head: true })
|
.select('user_id', { count: 'exact', head: true })
|
||||||
.eq('username', username.toLowerCase());
|
// citext compares CI — no manual normalization needed.
|
||||||
|
.eq('username', username.trim());
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return (count ?? 0) === 0;
|
return (count ?? 0) === 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export async function searchProfiles(
|
|||||||
query: string,
|
query: string,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
): Promise<ProfileBrief[]> {
|
): Promise<ProfileBrief[]> {
|
||||||
const trimmed = query.trim().toLowerCase();
|
const trimmed = query.trim();
|
||||||
if (trimmed.length < 2) return [];
|
if (trimmed.length < 2) return [];
|
||||||
const myId = await currentUserId(client);
|
const myId = await currentUserId(client);
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@
|
|||||||
"email_placeholder": "du@beispiel.de",
|
"email_placeholder": "du@beispiel.de",
|
||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
"username_placeholder": "dennis",
|
"username_placeholder": "dennis",
|
||||||
"username_hint": "Damit meldest du dich an. Nur Kleinbuchstaben.",
|
"username_hint": "Damit meldest du dich an. Groß-/Kleinschreibung bleibt, ist aber nicht unterscheidbar (dennis = Dennis).",
|
||||||
"username_invalid": "Kleinbuchstaben a–z, Ziffern, Unterstrich · 3–32 Zeichen.",
|
"username_invalid": "Buchstaben, Ziffern oder Unterstrich · 3–32 Zeichen.",
|
||||||
"invite_code": "Einladungscode",
|
"invite_code": "Einladungscode",
|
||||||
"invite_hint": "Pflichtfeld · Zugang nur auf Einladung."
|
"invite_hint": "Pflichtfeld · Zugang nur auf Einladung."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"network": "Netzwerkfehler. Prüfe deine Verbindung.",
|
"network": "Netzwerkfehler. Prüfe deine Verbindung.",
|
||||||
"ERR_NOT_AUTH": "Du bist nicht angemeldet.",
|
"ERR_NOT_AUTH": "Du bist nicht angemeldet.",
|
||||||
"ERR_INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
|
"ERR_INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
|
||||||
"ERR_USERNAME_INVALID": "Benutzername muss aus Kleinbuchstaben a–z, Ziffern oder Unterstrich bestehen (3–32 Zeichen).",
|
"ERR_USERNAME_INVALID": "Benutzername darf nur aus Buchstaben (A–Z, a–z), Ziffern oder Unterstrich bestehen (3–32 Zeichen).",
|
||||||
"ERR_INVITES_DISABLED": "Registrierungen sind derzeit vom Admin deaktiviert.",
|
"ERR_INVITES_DISABLED": "Registrierungen sind derzeit vom Admin deaktiviert.",
|
||||||
"ERR_INVITE_NOT_FOUND": "Einladungscode nicht gefunden.",
|
"ERR_INVITE_NOT_FOUND": "Einladungscode nicht gefunden.",
|
||||||
"ERR_INVITE_DISABLED": "Diese Einladung wurde deaktiviert.",
|
"ERR_INVITE_DISABLED": "Diese Einladung wurde deaktiviert.",
|
||||||
|
|||||||
@@ -30,8 +30,8 @@
|
|||||||
"email_placeholder": "you@example.com",
|
"email_placeholder": "you@example.com",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"username_placeholder": "dennis",
|
"username_placeholder": "dennis",
|
||||||
"username_hint": "You log in with this. Lowercase only.",
|
"username_hint": "You log in with this. Case is preserved but not unique (dennis = Dennis).",
|
||||||
"username_invalid": "Lowercase a–z, digits, underscore · 3–32 chars.",
|
"username_invalid": "Letters, digits, or underscore · 3–32 chars.",
|
||||||
"invite_code": "Invite code",
|
"invite_code": "Invite code",
|
||||||
"invite_hint": "Required · invite-only access."
|
"invite_hint": "Required · invite-only access."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"network": "Network error. Check your connection.",
|
"network": "Network error. Check your connection.",
|
||||||
"ERR_NOT_AUTH": "You are not signed in.",
|
"ERR_NOT_AUTH": "You are not signed in.",
|
||||||
"ERR_INVITE_CODE_REQUIRED": "Invite code is required.",
|
"ERR_INVITE_CODE_REQUIRED": "Invite code is required.",
|
||||||
"ERR_USERNAME_INVALID": "Username must be lowercase a–z, digits, underscore, 3–32 chars.",
|
"ERR_USERNAME_INVALID": "Username must be letters (A–Z, a–z), digits, or underscore, 3–32 chars.",
|
||||||
"ERR_INVITES_DISABLED": "Signups are currently disabled by the admin.",
|
"ERR_INVITES_DISABLED": "Signups are currently disabled by the admin.",
|
||||||
"ERR_INVITE_NOT_FOUND": "Invite code not found.",
|
"ERR_INVITE_NOT_FOUND": "Invite code not found.",
|
||||||
"ERR_INVITE_DISABLED": "This invite has been disabled.",
|
"ERR_INVITE_DISABLED": "This invite has been disabled.",
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- Keep raw casing on usernames at signup.
|
||||||
|
--
|
||||||
|
-- `profiles.username` is already `citext` so unique + lookup checks are
|
||||||
|
-- case-insensitive regardless of stored casing. Previously the signup
|
||||||
|
-- trigger force-lowercased via `lower(trim(...))`, losing the user's
|
||||||
|
-- preferred display case. Drop the lower(), widen the validation regex to
|
||||||
|
-- accept A-Z, keep trim() + uniqueness semantics.
|
||||||
|
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger language plpgsql security definer set search_path = public as $$
|
||||||
|
declare
|
||||||
|
v_invite_code text;
|
||||||
|
v_username text;
|
||||||
|
v_display_name text;
|
||||||
|
v_locale text;
|
||||||
|
v_invite public.invites%rowtype;
|
||||||
|
begin
|
||||||
|
v_invite_code := new.raw_user_meta_data->>'invite_code';
|
||||||
|
v_username := trim(new.raw_user_meta_data->>'username');
|
||||||
|
v_display_name := nullif(trim(new.raw_user_meta_data->>'display_name'), '');
|
||||||
|
v_locale := lower(trim(new.raw_user_meta_data->>'locale'));
|
||||||
|
|
||||||
|
if v_display_name is null then
|
||||||
|
v_display_name := v_username;
|
||||||
|
end if;
|
||||||
|
if v_locale is null or v_locale not in ('en', 'de') then
|
||||||
|
v_locale := 'en';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invite_code is null or length(v_invite_code) = 0 then
|
||||||
|
raise exception 'ERR_INVITE_CODE_REQUIRED';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_username is null or v_username !~ '^[A-Za-z0-9_]{3,32}$' then
|
||||||
|
raise exception 'ERR_USERNAME_INVALID';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not coalesce((select (value)::boolean from public.admin_settings where key = 'invites_enabled'), true) then
|
||||||
|
raise exception 'ERR_INVITES_DISABLED';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into v_invite from public.invites
|
||||||
|
where code = v_invite_code
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'ERR_INVITE_NOT_FOUND';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invite.disabled then
|
||||||
|
raise exception 'ERR_INVITE_DISABLED';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invite.expires_at is not null and v_invite.expires_at < now() then
|
||||||
|
raise exception 'ERR_INVITE_EXPIRED';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then
|
||||||
|
raise exception 'ERR_INVITE_EXHAUSTED';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.invites
|
||||||
|
set uses_count = uses_count + 1
|
||||||
|
where code = v_invite.code;
|
||||||
|
|
||||||
|
insert into public.profiles (user_id, username, display_name, locale)
|
||||||
|
values (new.id, v_username, v_display_name, v_locale);
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user