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
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:
@@ -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");
|
||||
|
||||
@@ -62,6 +62,8 @@ export type AdminCafe = {
|
||||
branchCount: number;
|
||||
employeeCount: number;
|
||||
createdAt: string;
|
||||
hasRecoveryKey: boolean;
|
||||
recoveryKeyCreatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type SupportTicket = {
|
||||
|
||||
Reference in New Issue
Block a user