Files
siftlode/frontend/src/components/KeyGate.tsx
T
peter 70bda98868 build(deps): migrate Tailwind CSS v3.4 → v4
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were
bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`,
`border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no
CSS rule at all — latent no-ops. v4 implements the opacity modifier via
`color-mix`, so the modifier now works with the existing hex vars, no per-scheme
token duplication needed.

- `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`,
  JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved),
  classname renames (rounded/shadow/blur scale, outline-none→outline-hidden,
  ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss,
  autoprefixer removed (v4 prefixes for you).
- `@theme` → `@theme inline` (load-bearing): the color vars are redefined at
  runtime under html[data-scheme]/[data-theme]; inline resolves them at the use
  site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern).
- Revert one codemod false-positive: it rewrote the English word "rounded" →
  "rounded-sm" inside a releaseNotes.ts prose sentence.

Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip.
Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15
alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/
radius/backdrop-filter render unchanged under v4.
2026-08-08 00:50:05 +02:00

179 lines
6.6 KiB
TypeScript

import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
import { api, HttpError } from "../lib/api";
import * as e2ee from "../lib/e2ee";
const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-hidden focus:border-accent";
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
// (the Messages page, a dock window) swaps out of the gate automatically. In unlock mode it also
// offers a forgotten-passphrase reset (the only escape from the set-once wall).
export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient();
const keyQ = useQuery({
queryKey: qk.messageKey(),
queryFn: api.messageMyKey,
staleTime: 60_000,
});
const configured = keyQ.data?.configured ?? false;
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts
// have no password to check. Reactive passive observer of the App-owned qk.me() query (enabled:
// false — App does the fetching), so hasPassword stays correct even if me resolves after mount.
const meQ = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const hasPassword = meQ.data?.has_password ?? false;
const [view, setView] = useState<"gate" | "reset">("gate");
const [pass, setPass] = useState("");
const [confirm, setConfirm] = useState("");
const [curPass, setCurPass] = useState("");
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit() {
setErr(null);
if (pass.length < 6) return setErr(t("messages.passTooShort"));
if (!configured && pass !== confirm) return setErr(t("messages.passMismatch"));
setBusy(true);
try {
if (configured) {
await e2ee.unlock(meId, pass, keyQ.data!);
} else {
const payload = await e2ee.setup(meId, pass);
await api.setupMessageKey(payload);
}
qc.invalidateQueries({ queryKey: qk.messageKey() });
} catch {
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
} finally {
setBusy(false);
}
}
function toReset() {
setErr(null);
setPass("");
setConfirm("");
setView("reset");
}
function cancelReset() {
setErr(null);
setCurPass("");
setView("gate");
}
async function doReset() {
setErr(null);
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
setBusy(true);
try {
await api.resetMessageKey(hasPassword ? curPass : undefined);
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears
// in setup mode. Invalidate last so the refetch sees configured:false.
await e2ee.resetLocal(meId);
setCurPass("");
setView("gate");
qc.invalidateQueries({ queryKey: qk.messageKey() });
} catch (e) {
setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed"));
} finally {
setBusy(false);
}
}
if (keyQ.isLoading)
return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
if (view === "reset")
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0" />
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
</div>
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
{hasPassword && (
<input
type="password"
autoComplete="current-password"
value={curPass}
onChange={(e) => setCurPass(e.target.value)}
placeholder={t("messages.resetCurrentPass")}
className={inputCls}
/>
)}
{err && <div className="text-sm text-red-400">{err}</div>}
<div className="flex gap-2">
<button
onClick={cancelReset}
disabled={busy}
className="px-4 py-2 rounded-xl text-sm border border-border hover:border-accent transition disabled:opacity-40"
>
{t("common.cancel")}
</button>
<button
onClick={doReset}
disabled={busy}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-red-500/90 text-white hover:bg-red-500 transition disabled:opacity-40"
>
<AlertTriangle className="w-4 h-4" />
{t("messages.resetButton")}
</button>
</div>
</div>
);
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-accent shrink-0" />
<div className="font-semibold text-sm">
{configured ? t("messages.unlockTitle") : t("messages.setupTitle")}
</div>
</div>
{!compact && (
<p className="text-sm text-muted">
{configured ? t("messages.unlockBody") : t("messages.setupBody")}
</p>
)}
<input
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
placeholder={t("messages.passphrase")}
className={inputCls}
/>
{!configured && (
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("messages.passphraseConfirm")}
className={inputCls}
/>
)}
{err && <div className="text-sm text-red-400">{err}</div>}
<button
onClick={submit}
disabled={busy || !pass}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition disabled:opacity-40"
>
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
</button>
{configured && (
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
{t("messages.forgotPass")}
</button>
)}
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
</div>
);
}