feat: username/password authentication for admin and merchant panels
CI/CD / CI · API (dotnet build + test) (push) Successful in 49s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled

- Add PasswordHasher utility (PBKDF2/SHA-256, 100k iterations)
- Add Username + PasswordHash fields to Employee and SystemAdmin entities
- EF migration: AddPasswordLogin (nullable columns on both tables)
- Meezi.API: POST /api/auth/login (employee password login, CHOOSE_CAFE support)
- Meezi.API: PUT/DELETE /api/cafes/{id}/employees/{id}/credentials (Owner/Manager only)
- Meezi.Admin.API: POST /api/admin/auth/login + PUT /api/admin/auth/password
- Dashboard login page: OTP / Password tabs
- Admin login page: OTP / Password tabs
- HR screen: new Credentials tab for setting employee username/password
- PlatformDataSeeder: ensure system admin + integration settings in production
- Trial countdown banner: updated deadline to 1 Tir 1405 (Jun 22)
- i18n: fa/en/ar updated for all new UI strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-31 19:58:54 +03:30
parent d0117f3171
commit 639d5c305e
27 changed files with 4257 additions and 40 deletions
@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiPut, apiDelete, ApiClientError } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface Employee {
id: string;
name: string;
phone: string;
role: string;
}
interface Props {
cafeId: string;
employees: Employee[];
}
export function EmployeeCredentialsPanel({ cafeId, employees }: Props) {
const t = useTranslations("hr.credentials");
const [selectedId, setSelectedId] = useState<string>("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [feedback, setFeedback] = useState<{ ok: boolean; msg: string } | null>(null);
const setMutation = useMutation({
mutationFn: () =>
apiPut(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`, {
username,
password,
}),
onSuccess: () => {
setFeedback({ ok: true, msg: t("saved") });
setPassword("");
},
onError: (err) => {
if (err instanceof ApiClientError && err.code === "USERNAME_TAKEN") {
setFeedback({ ok: false, msg: t("usernameTaken") });
} else {
setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) });
}
},
});
const removeMutation = useMutation({
mutationFn: () =>
apiDelete(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`),
onSuccess: () => {
setFeedback({ ok: true, msg: t("removed") });
setUsername("");
setPassword("");
},
onError: (err) => {
setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) });
},
});
const handleRemove = () => {
if (!window.confirm(t("removeConfirm"))) return;
setFeedback(null);
removeMutation.mutate();
};
const isPending = setMutation.isPending || removeMutation.isPending;
return (
<div className="space-y-4">
<div>
<p className="text-sm text-muted-foreground mb-3">{t("subtitle")}</p>
</div>
{/* Employee selector */}
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{employees.map((emp) => (
<button
key={emp.id}
type="button"
onClick={() => {
setSelectedId(emp.id);
setUsername("");
setPassword("");
setFeedback(null);
}}
className={`rounded-lg border p-3 text-start transition-colors cursor-pointer ${
selectedId === emp.id
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<p className="font-medium text-sm">{emp.name}</p>
<p className="text-xs text-muted-foreground" dir="ltr">{emp.phone}</p>
</button>
))}
</div>
{/* Form */}
{selectedId && (
<Card>
<CardHeader>
<CardTitle className="text-base">
{employees.find((e) => e.id === selectedId)?.name}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<LabeledField label={t("username")} htmlFor="cred-username">
<Input
id="cred-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
dir="ltr"
className="text-start"
autoComplete="off"
/>
</LabeledField>
<LabeledField label={t("password")} htmlFor="cred-password">
<Input
id="cred-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
dir="ltr"
className="text-start"
autoComplete="new-password"
/>
</LabeledField>
{feedback && (
<p className={`text-sm ${feedback.ok ? "text-green-600" : "text-destructive"}`}>
{feedback.msg}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
onClick={() => {
setFeedback(null);
setMutation.mutate();
}}
disabled={isPending || !username.trim() || password.length < 8}
>
{isPending ? "..." : t("set")}
</Button>
<Button
variant="outline"
onClick={handleRemove}
disabled={isPending}
>
{t("remove")}
</Button>
</div>
</CardContent>
</Card>
)}
{!selectedId && (
<p className="text-sm text-muted-foreground">{t("selectEmployee")}</p>
)}
</div>
);
}
@@ -12,6 +12,7 @@ import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel";
interface Employee {
id: string;
@@ -47,7 +48,7 @@ interface Salary {
isPaid: boolean;
}
type Tab = "attendance" | "leave" | "payroll" | "access";
type Tab = "attendance" | "leave" | "payroll" | "access" | "credentials";
export function HrScreen() {
const t = useTranslations("hr");
@@ -122,8 +123,8 @@ export function HrScreen() {
<h2 className="text-xl font-bold">{t("title")}</h2>
<div className="flex flex-wrap gap-2">
{((["attendance", "leave", "payroll", "access"] as Tab[]).filter(
(key) => key !== "access" || canManageAccess
{((["attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
(key) => (key !== "access" && key !== "credentials") || canManageAccess
)).map((key) => (
<Button
key={key}
@@ -230,6 +231,10 @@ export function HrScreen() {
)}
{tab === "access" && canManageAccess && <BranchAccessPanel cafeId={cafeId} />}
{tab === "credentials" && canManageAccess && (
<EmployeeCredentialsPanel cafeId={cafeId} employees={employees} />
)}
</div>
);
}
@@ -5,8 +5,8 @@ import { useLocale } from "next-intl";
import { useRouter } from "@/i18n/routing";
import { Clock, X, Zap } from "lucide-react";
// 14 Khordad 1405 = June 4, 2026 (Tehran UTC+3:30)
const DEADLINE = new Date("2026-06-04T00:00:00+03:30");
// 1 Tir 1405 = June 22, 2026 (Tehran IRDT UTC+4:30)
const DEADLINE = new Date("2026-06-22T00:00:00+04:30");
const STORAGE_KEY = "meezi_trial_banner_v1";
interface TimeLeft {
@@ -78,11 +78,11 @@ export function TrialCountdownBanner() {
const textFa = expired
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
: "دوره آزمایشی رایگان تا ۱۴ خرداد ۱۴۰۵";
: "دوره آزمایشی رایگان تا ۱ تیر ۱۴۰۵";
const textEn = expired
? "Your Meezi trial has ended. Choose a plan to continue."
: "Free trial ends 14 Khordad 1405 (Jun 4)";
: "Free trial ends 1 Tir 1405 (Jun 22)";
const Digit = ({ value, label }: { value: number; label: string }) => (
<div className="flex flex-col items-center">