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
@@ -38,6 +38,26 @@ public class AuthController : ControllerBase
_verifyRegisterValidator = verifyRegisterValidator;
}
[HttpPost("login")]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> LoginWithPassword(
[FromBody] LoginWithPasswordRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(ValidationError("Username and password are required."));
var (success, data, code, message, choices) = await _authService.LoginWithPasswordAsync(request, cancellationToken);
if (!success && code == "CHOOSE_CAFE")
return Ok(new ApiResponse<CafeChoicesResponse>(false, choices, new ApiError("CHOOSE_CAFE", "Please select a café to continue.")));
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("send-otp")]
[EnableRateLimiting("auth-otp")]
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
@@ -193,6 +213,9 @@ public class AuthController : ControllerBase
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
}
private static ApiResponse<object> ValidationError(string message) =>
new(false, null, new ApiError("VALIDATION_ERROR", message));
private IActionResult ErrorResult(string code, string message) => code switch
{
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
+69 -1
View File
@@ -1,9 +1,12 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Hr;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -15,17 +18,20 @@ public class HrController : CafeApiControllerBase
private readonly IValidator<CreateLeaveRequest> _leaveValidator;
private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
private readonly IValidator<CreateSalaryRequest> _salaryValidator;
private readonly AppDbContext _db;
public HrController(
IHrService hr,
IValidator<CreateLeaveRequest> leaveValidator,
IValidator<ReviewLeaveRequest> reviewValidator,
IValidator<CreateSalaryRequest> salaryValidator)
IValidator<CreateSalaryRequest> salaryValidator,
AppDbContext db)
{
_hr = hr;
_leaveValidator = leaveValidator;
_reviewValidator = reviewValidator;
_salaryValidator = salaryValidator;
_db = db;
}
[HttpGet("employees")]
@@ -201,4 +207,66 @@ public class HrController : CafeApiControllerBase
if (data is null) return NotFoundError();
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
}
/// <summary>Set or update username/password credentials for an employee. Owner/Manager only.</summary>
[HttpPut("employees/{employeeId}/credentials")]
public async Task<IActionResult> SetCredentials(
string cafeId,
string employeeId,
[FromBody] SetEmployeeCredentialsRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var username = request.Username.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(username))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Username is required.", "Username")));
if (request.Password.Length < 8)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Password must be at least 8 characters.", "Password")));
var employee = await _db.Employees
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
if (employee is null) return NotFoundError();
// Check username uniqueness within the cafe (excluding the employee itself)
var conflict = await _db.Employees
.AnyAsync(e => e.CafeId == cafeId && e.Id != employeeId && e.DeletedAt == null
&& e.Username != null && e.Username.ToLower() == username, ct);
if (conflict)
return Conflict(new ApiResponse<object>(false, null, new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.")));
employee.Username = username;
employee.PasswordHash = PasswordHasher.Hash(request.Password);
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<object>(true, null));
}
/// <summary>Remove username/password credentials from an employee. Owner/Manager only.</summary>
[HttpDelete("employees/{employeeId}/credentials")]
public async Task<IActionResult> RemoveCredentials(
string cafeId,
string employeeId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var employee = await _db.Employees
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
if (employee is null) return NotFoundError();
employee.Username = null;
employee.PasswordHash = null;
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<object>(true, null));
}
}
+3
View File
@@ -2,6 +2,9 @@ namespace Meezi.API.Models.Auth;
public record SendOtpRequest(string Phone);
/// <summary>Username + password login (alternative to OTP). Optional cafeId to scope to a specific café.</summary>
public record LoginWithPasswordRequest(string Username, string Password, string? CafeId = null);
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
public record RefreshTokenRequest(string RefreshToken);
+3
View File
@@ -59,3 +59,6 @@ public record CreateSalaryRequest(
decimal Deductions);
public record TodayShiftDto(ShiftType ShiftType, string Label);
/// <summary>Set or update username/password credentials for an employee.</summary>
public record SetEmployeeCredentialsRequest(string Username, string Password);
+55
View File
@@ -403,6 +403,61 @@ public class AuthService : IAuthService
return slug;
}
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default)
{
var username = request.Username.Trim();
var candidates = await _db.Employees
.Include(e => e.Cafe)
.Where(e => e.Username == username
&& e.PasswordHash != null
&& e.DeletedAt == null
&& e.Cafe.DeletedAt == null)
.ToListAsync(cancellationToken);
if (candidates.Count == 0)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
// Constant-time verification (check all matches to avoid username enumeration)
var matched = candidates.Where(e => PasswordHasher.Verify(request.Password, e.PasswordHash!)).ToList();
if (matched.Count == 0)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
// Scope to a specific café if requested
if (!string.IsNullOrWhiteSpace(request.CafeId))
{
matched = matched.Where(e => e.CafeId == request.CafeId).ToList();
if (matched.Count == 0)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
}
// Multiple cafés — ask frontend to pick one
if (matched.Count > 1)
{
var choices = new CafeChoicesResponse(
matched
.Where(e => e.Cafe is not null)
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList());
return (false, null, "CHOOSE_CAFE", null, choices);
}
var employee = matched[0];
if (employee.Cafe is null)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
var membershipDtos = matched
.Where(e => e.Cafe is not null)
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList();
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, null, cancellationToken);
return (true, tokens, null, null, null);
}
private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.Employee employee,
Core.Entities.Cafe cafe,
+4
View File
@@ -16,6 +16,10 @@ public interface IAuthService
VerifyOtpRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
string employeeId, string targetCafeId,
CancellationToken cancellationToken = default);
@@ -1,8 +1,11 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services;
using Meezi.Shared;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace Meezi.Admin.API.Controllers;
@@ -55,6 +58,39 @@ public class AdminAuthController : ControllerBase
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("login")]
public async Task<IActionResult> LoginWithPassword(
[FromBody] LoginWithPasswordRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(ValidationError("Username and password are required."));
var (success, data, code, message) = await _auth.LoginWithPasswordAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPut("password")]
[Authorize]
public async Task<IActionResult> ChangePassword(
[FromBody] ChangePasswordRequest request,
CancellationToken cancellationToken)
{
var adminId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(adminId))
return Unauthorized();
var (success, code, message) = await _auth.ChangePasswordAsync(adminId, request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<object>(true, null));
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
{
@@ -75,6 +111,9 @@ public class AdminAuthController : ControllerBase
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
}
private static ApiResponse<object> ValidationError(string message) =>
new(false, null, new ApiError("VALIDATION_ERROR", message));
private IActionResult ErrorResult(string code, string message) =>
code switch
{
+4
View File
@@ -8,6 +8,10 @@ public record VerifyOtpRequest(string Phone, string Code);
public record RefreshTokenRequest(string RefreshToken);
public record LoginWithPasswordRequest(string Username, string Password);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record AuthTokenResponse(
string AccessToken,
string RefreshToken,
@@ -19,6 +19,15 @@ public interface IAdminAuthService
VerifyOtpRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
string adminId,
ChangePasswordRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
RefreshTokenRequest request,
CancellationToken cancellationToken = default);
@@ -141,6 +150,49 @@ public class AdminAuthService : IAdminAuthService
return (true, tokens, null, null);
}
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default)
{
var username = request.Username.Trim();
var admin = await _db.SystemAdmins
.FirstOrDefaultAsync(a => a.Username == username && a.IsActive && a.DeletedAt == null, cancellationToken);
if (admin is null || string.IsNullOrWhiteSpace(admin.PasswordHash))
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
if (!PasswordHasher.Verify(request.Password, admin.PasswordHash))
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
var tokens = await IssueTokensAsync(admin, cancellationToken);
return (true, tokens, null, null);
}
public async Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
string adminId,
ChangePasswordRequest request,
CancellationToken cancellationToken = default)
{
var admin = await _db.SystemAdmins
.FirstOrDefaultAsync(a => a.Id == adminId && a.IsActive && a.DeletedAt == null, cancellationToken);
if (admin is null)
return (false, "NOT_FOUND", "Admin not found.");
// If a password is already set, require the current one
if (!string.IsNullOrWhiteSpace(admin.PasswordHash))
{
if (!PasswordHasher.Verify(request.CurrentPassword, admin.PasswordHash))
return (false, "INVALID_CREDENTIALS", "Current password is incorrect.");
}
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
return (false, "VALIDATION_ERROR", "New password must be at least 8 characters.");
admin.PasswordHash = PasswordHasher.Hash(request.NewPassword);
await _db.SaveChangesAsync(cancellationToken);
return (true, null, null);
}
private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.SystemAdmin admin,
CancellationToken cancellationToken)
+6
View File
@@ -12,6 +12,12 @@ public class Employee : TenantEntity
public decimal BaseSalary { get; set; }
public string? PinCode { get; set; }
/// <summary>Optional username for password-based dashboard/POS login (set by cafe admin).</summary>
public string? Username { get; set; }
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled for this employee.</summary>
public string? PasswordHash { get; set; }
public Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; }
public ICollection<Order> Orders { get; set; } = [];
+6
View File
@@ -5,4 +5,10 @@ public class SystemAdmin : BaseEntity
public string Name { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
/// <summary>Optional username for password-based login (alternative to OTP).</summary>
public string? Username { get; set; }
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled.</summary>
public string? PasswordHash { get; set; }
}
@@ -0,0 +1,40 @@
using System.Security.Cryptography;
namespace Meezi.Core.Utilities;
/// <summary>
/// PBKDF2/SHA-256 password hashing with no external dependencies.
/// Format stored: "{iterations}.{salt_b64}.{hash_b64}"
/// </summary>
public static class PasswordHasher
{
private const int SaltSize = 16; // 128-bit salt
private const int HashSize = 32; // 256-bit hash
private const int Iterations = 100_000; // NIST-recommended minimum
private static readonly HashAlgorithmName Algo = HashAlgorithmName.SHA256;
public static string Hash(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, Algo, HashSize);
return $"{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
}
public static bool Verify(string password, string storedHash)
{
var parts = storedHash.Split('.');
if (parts.Length != 3) return false;
if (!int.TryParse(parts[0], out var iterations)) return false;
byte[] salt, expectedHash;
try
{
salt = Convert.FromBase64String(parts[1]);
expectedHash = Convert.FromBase64String(parts[2]);
}
catch (FormatException) { return false; }
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, Algo, expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(actual, expectedHash);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPasswordLogin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PasswordHash",
table: "SystemAdmins",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Username",
table: "SystemAdmins",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PasswordHash",
table: "Employees",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Username",
table: "Employees",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PasswordHash",
table: "SystemAdmins");
migrationBuilder.DropColumn(
name: "Username",
table: "SystemAdmins");
migrationBuilder.DropColumn(
name: "PasswordHash",
table: "Employees");
migrationBuilder.DropColumn(
name: "Username",
table: "Employees");
}
}
}
@@ -929,6 +929,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<string>("NationalId")
.HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text");
@@ -939,6 +942,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<int>("Role")
.HasColumnType("integer");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("BranchId");
@@ -2119,11 +2125,17 @@ namespace Meezi.Infrastructure.Data.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Phone")
@@ -3,7 +3,9 @@ using Meezi.Core.Constants;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Platform;
using Meezi.Core.Utilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -17,18 +19,25 @@ public static class PlatformDataSeeder
public static async Task SeedAsync(IServiceProvider services)
{
var env = services.GetRequiredService<IHostEnvironment>();
if (!env.IsDevelopment())
return;
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
await EnsureCatalogUpgradesAsync(db, logger);
// Production-safe: ensure the platform owner's system-admin account exists
// on every boot (ALL environments) so the admin panel is reachable on a
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
await EnsureOwnerAdminAsync(db, config, logger);
if (!env.IsDevelopment())
{
// Production: also ensure integration settings (Kavenegar enabled/template,
// etc.) exist so the admin Integrations page is populated. Idempotent.
await EnsureIntegrationSettingsAsync(db, logger);
return;
}
await EnsureCatalogUpgradesAsync(db, logger);
await SeedSystemAdminAsync(db, logger);
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
@@ -36,6 +45,49 @@ public static class PlatformDataSeeder
await EnsureIntegrationSettingsAsync(db, logger);
}
/// <summary>
/// Ensures the platform owner's system-admin account exists in EVERY environment
/// (including production), so the admin panel is reachable on a fresh deploy.
/// The phone is configurable via "Seed:SystemAdminPhone" (env Seed__SystemAdminPhone)
/// and defaults to the platform owner's number. Idempotent — never duplicates.
/// </summary>
private static async Task EnsureOwnerAdminAsync(AppDbContext db, IConfiguration config, ILogger logger)
{
const string DefaultOwnerPhone = "09190345606";
var configured = config["Seed:SystemAdminPhone"];
var phone = PhoneNormalizer.Normalize(
string.IsNullOrWhiteSpace(configured) ? DefaultOwnerPhone : configured);
if (!PhoneNormalizer.IsValidIranMobile(phone))
{
logger.LogWarning("Owner system-admin seed skipped — invalid phone '{Phone}'", phone);
return;
}
if (await db.SystemAdmins.AnyAsync(a => a.Phone == phone))
return;
db.SystemAdmins.Add(new SystemAdmin
{
Id = "sysadmin_owner",
Name = "مدیر سامانه",
Phone = phone,
IsActive = true
});
try
{
await db.SaveChangesAsync();
logger.LogInformation("Seeded owner system admin with phone {Phone}", phone);
}
catch (DbUpdateException)
{
// api + admin-api boot concurrently against the same DB; another instance
// already inserted this admin. Safe to ignore.
logger.LogInformation("Owner system admin already seeded by another instance");
}
}
/// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary>
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
{
@@ -126,7 +178,7 @@ public static class PlatformDataSeeder
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"),
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"),
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
@@ -296,7 +348,7 @@ public static class PlatformDataSeeder
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"),
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"),
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
+8 -1
View File
@@ -1093,7 +1093,14 @@
"otp": "رمز التحقق",
"login": "دخول",
"error": "فشل تسجيل الدخول",
"devHint": "في التطوير يُطبع الرمز في سجل Admin API."
"devHint": "في التطوير يُطبع الرمز في سجل Admin API.",
"tabOtp": "رمز مؤقت",
"tabPassword": "كلمة المرور",
"username": "اسم المستخدم",
"usernamePlaceholder": "اسم المستخدم",
"password": "كلمة المرور",
"passwordPlaceholder": "كلمة المرور",
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة."
},
"dashboard": {
"title": "نظرة عامة",
+8 -1
View File
@@ -1086,7 +1086,14 @@
"otp": "Verification code",
"login": "Sign in",
"error": "Login failed",
"devHint": "In development the OTP is logged by Admin API (DEV admin OTP)."
"devHint": "In development the OTP is logged by Admin API (DEV admin OTP).",
"tabOtp": "One-time code",
"tabPassword": "Password",
"username": "Username",
"usernamePlaceholder": "Username",
"password": "Password",
"passwordPlaceholder": "Password",
"invalidCredentials": "Incorrect username or password."
},
"dashboard": {
"title": "Platform overview",
+8 -1
View File
@@ -1086,7 +1086,14 @@
"otp": "کد تأیید",
"login": "ورود",
"error": "خطا در ورود",
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ می‌شود (DEV admin OTP)."
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ می‌شود (DEV admin OTP).",
"tabOtp": "کد یکبارمصرف",
"tabPassword": "رمز عبور",
"username": "نام کاربری",
"usernamePlaceholder": "نام کاربری",
"password": "رمز عبور",
"passwordPlaceholder": "رمز عبور",
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است."
},
"dashboard": {
"title": "خلاصه سامانه",
+119 -8
View File
@@ -13,14 +13,25 @@ import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type LoginTab = "otp" | "password";
export default function AdminLoginPage() {
const t = useTranslations("admin.auth");
const tAuth = useTranslations("auth");
const router = useRouter();
const setAuth = useAdminAuthStore((s) => s.setAuth);
const [tab, setTab] = useState<LoginTab>("otp");
// OTP state
const [phone, setPhone] = useState("09120000001");
const [code, setCode] = useState("");
const [step, setStep] = useState<"phone" | "otp">("phone");
const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone");
// Password state
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -34,6 +45,8 @@ export default function AdminLoginPage() {
case "INVALID_OTP":
case "VALIDATION_ERROR":
return tAuth("invalidOtp");
case "INVALID_TOKEN":
return t("invalidCredentials");
default:
return err.message;
}
@@ -46,7 +59,7 @@ export default function AdminLoginPage() {
setError(null);
try {
await adminPost("/api/admin/auth/send-otp", { phone });
setStep("otp");
setOtpStep("otp");
setCode("");
} catch (e) {
setError(authErrorMessage(e));
@@ -55,7 +68,7 @@ export default function AdminLoginPage() {
}
};
const verify = async () => {
const verifyOtp = async () => {
const normalized = normalizeOtpInput(code);
if (normalized.length !== 6) {
setError(tAuth("invalidOtp"));
@@ -77,6 +90,34 @@ export default function AdminLoginPage() {
}
};
const loginWithPassword = async () => {
if (!username.trim() || !password) {
setError(t("invalidCredentials"));
return;
}
setLoading(true);
setError(null);
try {
const data = await adminPost<AuthTokenResponse>("/api/admin/auth/login", {
username: username.trim(),
password,
});
setAuth(data);
router.push("/admin");
} catch (e) {
setError(authErrorMessage(e));
} finally {
setLoading(false);
}
};
const switchTab = (next: LoginTab) => {
setTab(next);
setError(null);
setOtpStep("phone");
setCode("");
};
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl">
<Card className="w-full max-w-md">
@@ -87,8 +128,36 @@ export default function AdminLoginPage() {
<p className="text-center text-xs text-muted-foreground">{t("devHint")}</p>
) : null}
</CardHeader>
<CardContent className="space-y-4">
{step === "phone" ? (
{/* Tab switcher */}
<div className="flex border-b px-6">
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "otp"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("otp")}
>
{t("tabOtp")}
</button>
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "password"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("password")}
>
{t("tabPassword")}
</button>
</div>
<CardContent className="space-y-4 pt-4">
{/* ───── OTP tab ───── */}
{tab === "otp" && otpStep === "phone" && (
<form
className="space-y-4"
onSubmit={(e) => {
@@ -111,12 +180,14 @@ export default function AdminLoginPage() {
{loading ? "..." : t("sendOtp")}
</Button>
</form>
) : (
)}
{tab === "otp" && otpStep === "otp" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!loading) void verify();
if (!loading) void verifyOtp();
}}
>
<LabeledField label={t("otp")} htmlFor="admin-login-otp">
@@ -142,7 +213,7 @@ export default function AdminLoginPage() {
className="w-full"
disabled={loading}
onClick={() => {
setStep("phone");
setOtpStep("phone");
setCode("");
setError(null);
}}
@@ -151,6 +222,46 @@ export default function AdminLoginPage() {
</Button>
</form>
)}
{/* ───── Password tab ───── */}
{tab === "password" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!loading) void loginWithPassword();
}}
>
<LabeledField label={t("username")} htmlFor="admin-login-username">
<Input
id="admin-login-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
dir="ltr"
className="text-start"
autoComplete="username"
autoFocus
/>
</LabeledField>
<LabeledField label={t("password")} htmlFor="admin-login-password">
<Input
id="admin-login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
dir="ltr"
className="text-start"
autoComplete="current-password"
/>
</LabeledField>
<Button type="submit" className="w-full" disabled={loading || !username.trim() || !password}>
{loading ? "..." : t("login")}
</Button>
</form>
)}
{error ? <p className="text-center text-sm text-destructive">{error}</p> : null}
</CardContent>
</Card>
+26 -3
View File
@@ -45,7 +45,14 @@
"chooseCafe": "اختر المقهى",
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
"createNewCafe": "إنشاء مقهى جديد",
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟"
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟",
"tabOtp": "رمز مؤقت",
"tabPassword": "كلمة المرور",
"username": "اسم المستخدم",
"usernamePlaceholder": "اسم المستخدم",
"password": "كلمة المرور",
"passwordPlaceholder": "كلمة المرور",
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة."
},
"roles": {
"owner": "المالك",
@@ -386,7 +393,8 @@
"attendance": "الحضور",
"leave": "الإجازة",
"payroll": "الرواتب",
"access": "صلاحيات الفروع"
"access": "صلاحيات الفروع",
"credentials": "بيانات الدخول"
},
"myAttendance": "حضوري",
"clockIn": "تسجيل دخول",
@@ -396,7 +404,22 @@
"paid": "مدفوع",
"markPaid": "تسجيل الدفع",
"employeeCount": "الموظفون",
"monthYear": "شهر الرواتب"
"monthYear": "شهر الرواتب",
"credentials": {
"title": "بيانات دخول الموظفين",
"subtitle": "حدد اسم مستخدم وكلمة مرور لكل موظف حتى يتمكن من تسجيل الدخول دون رمز OTP.",
"selectEmployee": "اختر موظفاً أولاً",
"username": "اسم المستخدم",
"usernamePlaceholder": "مثال: ali_barista",
"password": "كلمة المرور (8 أحرف على الأقل)",
"passwordPlaceholder": "كلمة مرور جديدة",
"set": "حفظ بيانات الدخول",
"remove": "حذف بيانات الدخول",
"removeConfirm": "هل أنت متأكد؟ لن يتمكن الموظف من تسجيل الدخول بكلمة مرور بعد الآن.",
"saved": "تم حفظ بيانات الدخول.",
"removed": "تم حذف بيانات الدخول.",
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل."
}
},
"reviews": {
"title": "تقييمات العملاء",
+26 -3
View File
@@ -56,7 +56,14 @@
"chooseCafe": "Choose a café",
"chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.",
"createNewCafe": "Create a new café",
"createNewCafeHint": "Want to start your own café with this number?"
"createNewCafeHint": "Want to start your own café with this number?",
"tabOtp": "One-time code",
"tabPassword": "Password",
"username": "Username",
"usernamePlaceholder": "Username",
"password": "Password",
"passwordPlaceholder": "Password",
"invalidCredentials": "Incorrect username or password."
},
"roles": {
"owner": "Owner",
@@ -405,7 +412,8 @@
"attendance": "Attendance",
"leave": "Leave",
"payroll": "Payroll",
"access": "Branch access"
"access": "Branch access",
"credentials": "Login credentials"
},
"myAttendance": "My attendance",
"clockIn": "Clock in",
@@ -415,7 +423,22 @@
"paid": "Paid",
"markPaid": "Mark paid",
"employeeCount": "Employees",
"monthYear": "Payroll month"
"monthYear": "Payroll month",
"credentials": {
"title": "Employee login credentials",
"subtitle": "Set a username and password for each employee so they can sign in without an OTP.",
"selectEmployee": "Select an employee first",
"username": "Username",
"usernamePlaceholder": "e.g. ali_barista",
"password": "Password (min 8 characters)",
"passwordPlaceholder": "New password",
"set": "Save credentials",
"remove": "Remove credentials",
"removeConfirm": "Are you sure? The employee will no longer be able to sign in with a password.",
"saved": "Credentials saved.",
"removed": "Credentials removed.",
"usernameTaken": "This username is already taken."
}
},
"reviews": {
"title": "Customer reviews",
+26 -3
View File
@@ -56,7 +56,14 @@
"chooseCafe": "انتخاب کافه",
"chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.",
"createNewCafe": "ایجاد کافه جدید",
"createNewCafeHint": "می‌خواهید کافه خودتان را با همین شماره راه‌اندازی کنید؟"
"createNewCafeHint": "می‌خواهید کافه خودتان را با همین شماره راه‌اندازی کنید؟",
"tabOtp": "کد یکبارمصرف",
"tabPassword": "رمز عبور",
"username": "نام کاربری",
"usernamePlaceholder": "نام کاربری",
"password": "رمز عبور",
"passwordPlaceholder": "رمز عبور",
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است."
},
"roles": {
"owner": "مالک",
@@ -405,7 +412,8 @@
"attendance": "حضور و غیاب",
"leave": "مرخصی",
"payroll": "حقوق",
"access": "دسترسی شعب"
"access": "دسترسی شعب",
"credentials": "رمز ورود"
},
"myAttendance": "حضور من",
"clockIn": "ورود",
@@ -415,7 +423,22 @@
"paid": "پرداخت شده",
"markPaid": "ثبت پرداخت",
"employeeCount": "تعداد کارمندان",
"monthYear": "ماه حقوق"
"monthYear": "ماه حقوق",
"credentials": {
"title": "مدیریت رمز ورود کارمندان",
"subtitle": "برای هر کارمند می‌توانید نام کاربری و رمز عبور تعریف کنید تا بدون نیاز به کد OTP وارد شوند.",
"selectEmployee": "ابتدا یک کارمند انتخاب کنید",
"username": "نام کاربری",
"usernamePlaceholder": "مثال: ali_barista",
"password": "رمز عبور (حداقل ۸ کاراکتر)",
"passwordPlaceholder": "رمز عبور جدید",
"set": "ذخیره رمز ورود",
"remove": "حذف رمز ورود",
"removeConfirm": "آیا مطمئنید؟ کارمند دیگر نمی‌تواند با رمز عبور وارد شود.",
"saved": "رمز ورود ذخیره شد.",
"removed": "رمز ورود حذف شد.",
"usernameTaken": "این نام کاربری قبلاً استفاده شده است."
}
},
"reviews": {
"title": "نظرات مشتریان",
+125 -6
View File
@@ -12,14 +12,24 @@ 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";
export default function LoginPage() {
const t = useTranslations("auth");
const router = useRouter();
const setAuth = useAuthStore((s) => s.setAuth);
const [tab, setTab] = useState<LoginTab>("otp");
// OTP state
const [phone, setPhone] = useState("09121234567");
const [code, setCode] = useState("");
const [step, setStep] = useState<"phone" | "otp">("phone");
const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone");
// Password state
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -32,6 +42,9 @@ export default function LoginPage() {
return t("smsFailed");
case "INVALID_OTP":
return t("invalidOtp");
case "INVALID_TOKEN":
case "NOT_FOUND":
return tab === "password" ? t("invalidCredentials") : t("notFound");
default:
return err.message;
}
@@ -44,7 +57,7 @@ export default function LoginPage() {
setError(null);
try {
await apiPost("/api/auth/send-otp", { phone });
setStep("otp");
setOtpStep("otp");
} catch (e) {
if (e instanceof ApiClientError && e.code === "NOT_FOUND") {
// No account → take them to register with phone pre-filled
@@ -74,6 +87,34 @@ export default function LoginPage() {
}
};
const loginWithPassword = async () => {
if (!username.trim() || !password) {
setError(t("invalidCredentials"));
return;
}
setLoading(true);
setError(null);
try {
const data = await apiPost<AuthTokenResponse>("/api/auth/login", {
username: username.trim(),
password,
});
setAuth(data);
router.push("/pos");
} catch (e) {
setError(authErrorMessage(e));
} finally {
setLoading(false);
}
};
const switchTab = (next: LoginTab) => {
setTab(next);
setError(null);
setOtpStep("phone");
setCode("");
};
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<Card className="w-full max-w-md">
@@ -81,8 +122,36 @@ export default function LoginPage() {
<CardTitle className="text-center text-primary">{t("title")}</CardTitle>
<p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p>
</CardHeader>
<CardContent className="space-y-4">
{step === "phone" ? (
{/* Tab switcher */}
<div className="flex border-b px-6">
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "otp"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("otp")}
>
{t("tabOtp")}
</button>
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "password"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("password")}
>
{t("tabPassword")}
</button>
</div>
<CardContent className="space-y-4 pt-4">
{/* ───── OTP tab ───── */}
{tab === "otp" && otpStep === "phone" && (
<form
className="space-y-4"
onSubmit={(e) => {
@@ -105,7 +174,9 @@ export default function LoginPage() {
{loading ? "..." : t("sendOtp")}
</Button>
</form>
) : (
)}
{tab === "otp" && otpStep === "otp" && (
<form
className="space-y-4"
onSubmit={(e) => {
@@ -128,12 +199,60 @@ export default function LoginPage() {
type="button"
variant="ghost"
className="w-full"
onClick={() => setStep("phone")}
onClick={() => {
setOtpStep("phone");
setCode("");
setError(null);
}}
>
{t("resend")}
</Button>
</form>
)}
{/* ───── Password tab ───── */}
{tab === "password" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!loading) void loginWithPassword();
}}
>
<LabeledField label={t("username")} htmlFor="login-username">
<Input
id="login-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
dir="ltr"
className="text-start"
autoComplete="username"
autoFocus
/>
</LabeledField>
<LabeledField label={t("password")} htmlFor="login-password">
<Input
id="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
dir="ltr"
className="text-start"
autoComplete="current-password"
/>
</LabeledField>
<Button
type="submit"
className="w-full"
disabled={loading || !username.trim() || !password}
>
{loading ? "..." : t("verify")}
</Button>
</form>
)}
{error && (
<p className="text-center text-sm text-destructive">{error}</p>
)}
@@ -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">