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:
@@ -61,6 +61,11 @@
|
||||
"password": "كلمة المرور",
|
||||
"passwordPlaceholder": "كلمة المرور",
|
||||
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة.",
|
||||
"invalidKey": "مفتاح الاستعادة غير صالح.",
|
||||
"recoveryKey": "مفتاح الاستعادة",
|
||||
"keyHint": "أدخل مفتاح الاستعادة الذي حصلت عليه من دعم ميزي.",
|
||||
"useRecoveryKey": "فقدت الوصول؟ سجّل الدخول بمفتاح الاستعادة",
|
||||
"backToNormalLogin": "العودة إلى تسجيل الدخول العادي",
|
||||
"kojaSlug": "عنوان الملف الشخصي في كوجا",
|
||||
"kojaSlugHint": "يجد الزوار مقهاكم على هذا العنوان",
|
||||
"kojaSlugPlaceholder": "مثال: my-cafe"
|
||||
|
||||
@@ -72,6 +72,11 @@
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Password",
|
||||
"invalidCredentials": "Incorrect username or password.",
|
||||
"invalidKey": "Invalid recovery key.",
|
||||
"recoveryKey": "Recovery key",
|
||||
"keyHint": "Enter the recovery key you received from Meezi support.",
|
||||
"useRecoveryKey": "Lost access? Sign in with a recovery key",
|
||||
"backToNormalLogin": "Back to normal sign-in",
|
||||
"kojaSlug": "Koja profile address",
|
||||
"kojaSlugHint": "Customers will find your cafe at this address",
|
||||
"kojaSlugPlaceholder": "e.g. my-cafe"
|
||||
|
||||
@@ -72,6 +72,11 @@
|
||||
"password": "رمز عبور",
|
||||
"passwordPlaceholder": "رمز عبور",
|
||||
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است.",
|
||||
"invalidKey": "کلید بازیابی نامعتبر است.",
|
||||
"recoveryKey": "کلید بازیابی",
|
||||
"keyHint": "کلید بازیابی را که از پشتیبانی میزی دریافت کردهاید وارد کنید.",
|
||||
"useRecoveryKey": "دسترسی ندارید؟ ورود با کلید بازیابی",
|
||||
"backToNormalLogin": "بازگشت به ورود عادی",
|
||||
"kojaSlug": "آدرس پروفایل در کوجا",
|
||||
"kojaSlugHint": "بازدیدکنندگان از این آدرس کافه شما را پیدا میکنند",
|
||||
"kojaSlugPlaceholder": "مثال: cafe-roya"
|
||||
|
||||
@@ -12,7 +12,7 @@ import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { OtpInput } from "@/components/ui/otp-input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
type LoginTab = "otp" | "password";
|
||||
type LoginTab = "otp" | "password" | "key";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("auth");
|
||||
@@ -30,6 +30,9 @@ export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
// Recovery-key state (admin-issued key when OTP access is lost)
|
||||
const [recoveryKey, setRecoveryKey] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -42,6 +45,8 @@ export default function LoginPage() {
|
||||
return t("smsFailed");
|
||||
case "INVALID_OTP":
|
||||
return t("invalidOtp");
|
||||
case "INVALID_KEY":
|
||||
return t("invalidKey");
|
||||
case "INVALID_TOKEN":
|
||||
case "NOT_FOUND":
|
||||
return tab === "password" ? t("invalidCredentials") : t("notFound");
|
||||
@@ -108,6 +113,26 @@ export default function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loginWithRecoveryKey = async () => {
|
||||
if (!recoveryKey.trim()) {
|
||||
setError(t("invalidKey"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiPost<AuthTokenResponse>("/api/auth/login-key", {
|
||||
key: recoveryKey.trim(),
|
||||
});
|
||||
setAuth(data);
|
||||
router.push("/pos");
|
||||
} catch (e) {
|
||||
setError(authErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const switchTab = (next: LoginTab) => {
|
||||
setTab(next);
|
||||
setError(null);
|
||||
@@ -253,16 +278,67 @@ export default function LoginPage() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ───── Recovery key tab ───── */}
|
||||
{tab === "key" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!loading) void loginWithRecoveryKey();
|
||||
}}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{t("keyHint")}</p>
|
||||
<LabeledField label={t("recoveryKey")} htmlFor="login-key">
|
||||
<Input
|
||||
id="login-key"
|
||||
value={recoveryKey}
|
||||
onChange={(e) => setRecoveryKey(e.target.value)}
|
||||
placeholder="MZ-XXXXX-XXXXX-XXXXX-XXXXX"
|
||||
dir="ltr"
|
||||
className="text-start font-mono tracking-wider"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button type="submit" className="w-full" disabled={loading || !recoveryKey.trim()}>
|
||||
{loading ? "..." : t("verify")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-center text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("noAccount")}{" "}
|
||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||
{t("registerLink")}
|
||||
</Link>
|
||||
</p>
|
||||
{tab === "key" ? (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchTab("otp")}
|
||||
className="font-medium text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
{t("backToNormalLogin")}
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("noAccount")}{" "}
|
||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||
{t("registerLink")}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchTab("key")}
|
||||
className="hover:underline cursor-pointer"
|
||||
>
|
||||
{t("useRecoveryKey")}
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user