feat(auth): admin-issued café recovery key login
CI/CD / CI · API (dotnet build + test) (push) Successful in 5m6s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 1m0s
CI/CD / Deploy · all services (push) Successful in 5m31s

Platform admins can generate a permanent recovery key per café (admin
panel → Cafés). The café Owner uses it to sign in when OTP access is lost;
once authenticated, all server-side data syncs as normal (data is per-café
on the server, the device only caches it).

Backend:
- Cafe.RecoveryKeyHash (SHA-256, unique index) + RecoveryKeyCreatedAt; migration
- RecoveryKeyGenerator util: MZ-XXXXX-XXXXX-XXXXX-XXXXX, ~190-bit entropy,
  stored as SHA-256 (API-token pattern — raw key shown once, never retrievable)
- Admin: POST/DELETE /api/admin/cafes/{id}/recovery-key (key returned once);
  café list now reports HasRecoveryKey + RecoveryKeyCreatedAt
- Login: POST /api/auth/login-key → exact-hash lookup → resolves café Owner →
  issues normal JWT; rate-limited (auth-otp), suspended/no-owner guarded, logged

Admin UI: per-café generate / regenerate / revoke with one-time reveal + copy.
Dashboard login: discreet "ورود با کلید بازیابی" link → key field. fa/en/ar.

86 tests pass; all tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-15 15:10:11 +03:30
parent 76d4434581
commit a855cf1d80
19 changed files with 3871 additions and 10 deletions
@@ -493,6 +493,7 @@ export function AdminCafesScreen() {
</Button>
</div>
</div>
<RecoveryKeyPanel cafe={c} />
{profileCafeId === c.id ? (
<CafeDiscoverProfilePanel cafeId={c.id} mode="admin" compact />
) : null}
@@ -503,6 +504,93 @@ export function AdminCafesScreen() {
);
}
/**
* Generate / revoke a café's permanent recovery key. The raw key is returned
* once on generate — shown here for copy, never retrievable again.
*/
function RecoveryKeyPanel({ cafe }: { cafe: AdminCafe }) {
const t = useTranslations("admin.cafes.recoveryKey");
const qc = useQueryClient();
const [revealed, setRevealed] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const generate = useMutation({
mutationFn: () => adminPost<{ cafeId: string; key: string }>(`/api/admin/cafes/${cafe.id}/recovery-key`),
onSuccess: (data) => {
setRevealed(data.key);
setCopied(false);
void qc.invalidateQueries({ queryKey: ["admin", "cafes"] });
},
onError: () => notify.error(t("generateFailed")),
});
const revoke = useMutation({
mutationFn: () => adminDelete(`/api/admin/cafes/${cafe.id}/recovery-key`),
onSuccess: () => {
setRevealed(null);
notify.success(t("revoked"));
void qc.invalidateQueries({ queryKey: ["admin", "cafes"] });
},
onError: () => notify.error(t("revokeFailed")),
});
const copy = async () => {
if (!revealed) return;
try {
await navigator.clipboard.writeText(revealed);
setCopied(true);
} catch {
/* clipboard blocked — user can still select the text */
}
};
return (
<div className="rounded-lg border border-border/70 bg-muted/20 p-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="font-medium">{t("title")}</p>
<p className="text-xs text-muted-foreground">
{cafe.hasRecoveryKey ? t("active") : t("none")}
{cafe.hasRecoveryKey && cafe.recoveryKeyCreatedAt
? ` · ${new Date(cafe.recoveryKeyCreatedAt).toLocaleDateString("fa-IR")}`
: ""}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => generate.mutate()} disabled={generate.isPending}>
{cafe.hasRecoveryKey ? t("regenerate") : t("generate")}
</Button>
{cafe.hasRecoveryKey ? (
<Button
size="sm"
variant="outline"
className="border-destructive text-destructive hover:bg-destructive/10"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{t("revoke")}
</Button>
) : null}
</div>
</div>
{revealed ? (
<div className="mt-3 space-y-2 rounded-lg border border-primary/30 bg-primary/5 p-3">
<p className="text-xs font-medium text-primary">{t("revealHint")}</p>
<div className="flex items-center gap-2">
<code className="flex-1 select-all rounded bg-background px-3 py-2 font-mono text-base tracking-wider" dir="ltr">
{revealed}
</code>
<Button size="sm" onClick={() => void copy()}>
{copied ? t("copied") : t("copy")}
</Button>
</div>
</div>
) : null}
</div>
);
}
export function AdminTicketsScreen() {
const t = useTranslations("admin.tickets");
const [filter, setFilter] = useState<"all" | "open" | "closed">("all");
+2
View File
@@ -62,6 +62,8 @@ export type AdminCafe = {
branchCount: number;
employeeCount: number;
createdAt: string;
hasRecoveryKey: boolean;
recoveryKeyCreatedAt?: string | null;
};
export type SupportTicket = {