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
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:
@@ -38,6 +38,26 @@ public class AuthController : ControllerBase
|
|||||||
_verifyRegisterValidator = verifyRegisterValidator;
|
_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")]
|
[HttpPost("send-otp")]
|
||||||
[EnableRateLimiting("auth-otp")]
|
[EnableRateLimiting("auth-otp")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
[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));
|
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
|
private IActionResult ErrorResult(string code, string message) => code switch
|
||||||
{
|
{
|
||||||
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Hr;
|
using Meezi.API.Models.Hr;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Core.Utilities;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -15,17 +18,20 @@ public class HrController : CafeApiControllerBase
|
|||||||
private readonly IValidator<CreateLeaveRequest> _leaveValidator;
|
private readonly IValidator<CreateLeaveRequest> _leaveValidator;
|
||||||
private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
|
private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
|
||||||
private readonly IValidator<CreateSalaryRequest> _salaryValidator;
|
private readonly IValidator<CreateSalaryRequest> _salaryValidator;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
public HrController(
|
public HrController(
|
||||||
IHrService hr,
|
IHrService hr,
|
||||||
IValidator<CreateLeaveRequest> leaveValidator,
|
IValidator<CreateLeaveRequest> leaveValidator,
|
||||||
IValidator<ReviewLeaveRequest> reviewValidator,
|
IValidator<ReviewLeaveRequest> reviewValidator,
|
||||||
IValidator<CreateSalaryRequest> salaryValidator)
|
IValidator<CreateSalaryRequest> salaryValidator,
|
||||||
|
AppDbContext db)
|
||||||
{
|
{
|
||||||
_hr = hr;
|
_hr = hr;
|
||||||
_leaveValidator = leaveValidator;
|
_leaveValidator = leaveValidator;
|
||||||
_reviewValidator = reviewValidator;
|
_reviewValidator = reviewValidator;
|
||||||
_salaryValidator = salaryValidator;
|
_salaryValidator = salaryValidator;
|
||||||
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("employees")]
|
[HttpGet("employees")]
|
||||||
@@ -201,4 +207,66 @@ public class HrController : CafeApiControllerBase
|
|||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ namespace Meezi.API.Models.Auth;
|
|||||||
|
|
||||||
public record SendOtpRequest(string Phone);
|
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 VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
|
||||||
|
|
||||||
public record RefreshTokenRequest(string RefreshToken);
|
public record RefreshTokenRequest(string RefreshToken);
|
||||||
|
|||||||
@@ -59,3 +59,6 @@ public record CreateSalaryRequest(
|
|||||||
decimal Deductions);
|
decimal Deductions);
|
||||||
|
|
||||||
public record TodayShiftDto(ShiftType ShiftType, string Label);
|
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);
|
||||||
|
|||||||
@@ -403,6 +403,61 @@ public class AuthService : IAuthService
|
|||||||
return slug;
|
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(
|
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||||
Core.Entities.Employee employee,
|
Core.Entities.Employee employee,
|
||||||
Core.Entities.Cafe cafe,
|
Core.Entities.Cafe cafe,
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ public interface IAuthService
|
|||||||
VerifyOtpRequest request,
|
VerifyOtpRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
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(
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
|
||||||
string employeeId, string targetCafeId,
|
string employeeId, string targetCafeId,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.Admin.API.Models;
|
using Meezi.Admin.API.Models;
|
||||||
using Meezi.Admin.API.Services;
|
using Meezi.Admin.API.Services;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace Meezi.Admin.API.Controllers;
|
namespace Meezi.Admin.API.Controllers;
|
||||||
|
|
||||||
@@ -55,6 +58,39 @@ public class AdminAuthController : ControllerBase
|
|||||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
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")]
|
[HttpPost("refresh")]
|
||||||
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
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));
|
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) =>
|
private IActionResult ErrorResult(string code, string message) =>
|
||||||
code switch
|
code switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ public record VerifyOtpRequest(string Phone, string Code);
|
|||||||
|
|
||||||
public record RefreshTokenRequest(string RefreshToken);
|
public record RefreshTokenRequest(string RefreshToken);
|
||||||
|
|
||||||
|
public record LoginWithPasswordRequest(string Username, string Password);
|
||||||
|
|
||||||
|
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||||
|
|
||||||
public record AuthTokenResponse(
|
public record AuthTokenResponse(
|
||||||
string AccessToken,
|
string AccessToken,
|
||||||
string RefreshToken,
|
string RefreshToken,
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ public interface IAdminAuthService
|
|||||||
VerifyOtpRequest request,
|
VerifyOtpRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
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(
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
|
||||||
RefreshTokenRequest request,
|
RefreshTokenRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
@@ -141,6 +150,49 @@ public class AdminAuthService : IAdminAuthService
|
|||||||
return (true, tokens, null, null);
|
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(
|
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||||
Core.Entities.SystemAdmin admin,
|
Core.Entities.SystemAdmin admin,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ public class Employee : TenantEntity
|
|||||||
public decimal BaseSalary { get; set; }
|
public decimal BaseSalary { get; set; }
|
||||||
public string? PinCode { 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 Cafe Cafe { get; set; } = null!;
|
||||||
public Branch? Branch { get; set; }
|
public Branch? Branch { get; set; }
|
||||||
public ICollection<Order> Orders { get; set; } = [];
|
public ICollection<Order> Orders { get; set; } = [];
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ public class SystemAdmin : BaseEntity
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string Phone { get; set; } = string.Empty;
|
public string Phone { get; set; } = string.Empty;
|
||||||
public bool IsActive { get; set; } = true;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3299
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")
|
b.Property<string>("NationalId")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -939,6 +942,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Property<int>("Role")
|
b.Property<int>("Role")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("BranchId");
|
b.HasIndex("BranchId");
|
||||||
@@ -2119,11 +2125,17 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Phone")
|
b.HasIndex("Phone")
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ using Meezi.Core.Constants;
|
|||||||
using Meezi.Core.Entities;
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Platform;
|
using Meezi.Core.Platform;
|
||||||
|
using Meezi.Core.Utilities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -17,18 +19,25 @@ public static class PlatformDataSeeder
|
|||||||
public static async Task SeedAsync(IServiceProvider services)
|
public static async Task SeedAsync(IServiceProvider services)
|
||||||
{
|
{
|
||||||
var env = services.GetRequiredService<IHostEnvironment>();
|
var env = services.GetRequiredService<IHostEnvironment>();
|
||||||
if (!env.IsDevelopment())
|
|
||||||
return;
|
|
||||||
|
|
||||||
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
|
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
|
||||||
await using var scope = services.CreateAsyncScope();
|
await using var scope = services.CreateAsyncScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
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())
|
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;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnsureCatalogUpgradesAsync(db, logger);
|
||||||
await SeedSystemAdminAsync(db, logger);
|
await SeedSystemAdminAsync(db, logger);
|
||||||
await SeedPlansAsync(db, logger);
|
await SeedPlansAsync(db, logger);
|
||||||
await SeedFeaturesAsync(db, logger);
|
await SeedFeaturesAsync(db, logger);
|
||||||
@@ -36,6 +45,49 @@ public static class PlatformDataSeeder
|
|||||||
await EnsureIntegrationSettingsAsync(db, logger);
|
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>
|
/// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary>
|
||||||
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
|
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
|
||||||
{
|
{
|
||||||
@@ -126,7 +178,7 @@ public static class PlatformDataSeeder
|
|||||||
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
||||||
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
||||||
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
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.enabled", "false", "integrations", "فعال OpenAI"),
|
||||||
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
||||||
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
||||||
@@ -296,7 +348,7 @@ public static class PlatformDataSeeder
|
|||||||
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
||||||
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
||||||
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
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.enabled", "false", "integrations", "فعال OpenAI"),
|
||||||
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
||||||
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
||||||
|
|||||||
@@ -1093,7 +1093,14 @@
|
|||||||
"otp": "رمز التحقق",
|
"otp": "رمز التحقق",
|
||||||
"login": "دخول",
|
"login": "دخول",
|
||||||
"error": "فشل تسجيل الدخول",
|
"error": "فشل تسجيل الدخول",
|
||||||
"devHint": "في التطوير يُطبع الرمز في سجل Admin API."
|
"devHint": "في التطوير يُطبع الرمز في سجل Admin API.",
|
||||||
|
"tabOtp": "رمز مؤقت",
|
||||||
|
"tabPassword": "كلمة المرور",
|
||||||
|
"username": "اسم المستخدم",
|
||||||
|
"usernamePlaceholder": "اسم المستخدم",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"passwordPlaceholder": "كلمة المرور",
|
||||||
|
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "نظرة عامة",
|
"title": "نظرة عامة",
|
||||||
|
|||||||
@@ -1086,7 +1086,14 @@
|
|||||||
"otp": "Verification code",
|
"otp": "Verification code",
|
||||||
"login": "Sign in",
|
"login": "Sign in",
|
||||||
"error": "Login failed",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Platform overview",
|
"title": "Platform overview",
|
||||||
|
|||||||
@@ -1086,7 +1086,14 @@
|
|||||||
"otp": "کد تأیید",
|
"otp": "کد تأیید",
|
||||||
"login": "ورود",
|
"login": "ورود",
|
||||||
"error": "خطا در ورود",
|
"error": "خطا در ورود",
|
||||||
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ میشود (DEV admin OTP)."
|
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ میشود (DEV admin OTP).",
|
||||||
|
"tabOtp": "کد یکبارمصرف",
|
||||||
|
"tabPassword": "رمز عبور",
|
||||||
|
"username": "نام کاربری",
|
||||||
|
"usernamePlaceholder": "نام کاربری",
|
||||||
|
"password": "رمز عبور",
|
||||||
|
"passwordPlaceholder": "رمز عبور",
|
||||||
|
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "خلاصه سامانه",
|
"title": "خلاصه سامانه",
|
||||||
|
|||||||
@@ -13,14 +13,25 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
type LoginTab = "otp" | "password";
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const t = useTranslations("admin.auth");
|
const t = useTranslations("admin.auth");
|
||||||
const tAuth = useTranslations("auth");
|
const tAuth = useTranslations("auth");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setAuth = useAdminAuthStore((s) => s.setAuth);
|
const setAuth = useAdminAuthStore((s) => s.setAuth);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<LoginTab>("otp");
|
||||||
|
|
||||||
|
// OTP state
|
||||||
const [phone, setPhone] = useState("09120000001");
|
const [phone, setPhone] = useState("09120000001");
|
||||||
const [code, setCode] = useState("");
|
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 [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -34,6 +45,8 @@ export default function AdminLoginPage() {
|
|||||||
case "INVALID_OTP":
|
case "INVALID_OTP":
|
||||||
case "VALIDATION_ERROR":
|
case "VALIDATION_ERROR":
|
||||||
return tAuth("invalidOtp");
|
return tAuth("invalidOtp");
|
||||||
|
case "INVALID_TOKEN":
|
||||||
|
return t("invalidCredentials");
|
||||||
default:
|
default:
|
||||||
return err.message;
|
return err.message;
|
||||||
}
|
}
|
||||||
@@ -46,7 +59,7 @@ export default function AdminLoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await adminPost("/api/admin/auth/send-otp", { phone });
|
await adminPost("/api/admin/auth/send-otp", { phone });
|
||||||
setStep("otp");
|
setOtpStep("otp");
|
||||||
setCode("");
|
setCode("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(authErrorMessage(e));
|
setError(authErrorMessage(e));
|
||||||
@@ -55,7 +68,7 @@ export default function AdminLoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const verify = async () => {
|
const verifyOtp = async () => {
|
||||||
const normalized = normalizeOtpInput(code);
|
const normalized = normalizeOtpInput(code);
|
||||||
if (normalized.length !== 6) {
|
if (normalized.length !== 6) {
|
||||||
setError(tAuth("invalidOtp"));
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl">
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl">
|
||||||
<Card className="w-full max-w-md">
|
<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>
|
<p className="text-center text-xs text-muted-foreground">{t("devHint")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</CardHeader>
|
</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
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -111,12 +180,14 @@ export default function AdminLoginPage() {
|
|||||||
{loading ? "..." : t("sendOtp")}
|
{loading ? "..." : t("sendOtp")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{tab === "otp" && otpStep === "otp" && (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!loading) void verify();
|
if (!loading) void verifyOtp();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LabeledField label={t("otp")} htmlFor="admin-login-otp">
|
<LabeledField label={t("otp")} htmlFor="admin-login-otp">
|
||||||
@@ -142,7 +213,7 @@ export default function AdminLoginPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStep("phone");
|
setOtpStep("phone");
|
||||||
setCode("");
|
setCode("");
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
@@ -151,6 +222,46 @@ export default function AdminLoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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}
|
{error ? <p className="text-center text-sm text-destructive">{error}</p> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -45,7 +45,14 @@
|
|||||||
"chooseCafe": "اختر المقهى",
|
"chooseCafe": "اختر المقهى",
|
||||||
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
|
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
|
||||||
"createNewCafe": "إنشاء مقهى جديد",
|
"createNewCafe": "إنشاء مقهى جديد",
|
||||||
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟"
|
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟",
|
||||||
|
"tabOtp": "رمز مؤقت",
|
||||||
|
"tabPassword": "كلمة المرور",
|
||||||
|
"username": "اسم المستخدم",
|
||||||
|
"usernamePlaceholder": "اسم المستخدم",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"passwordPlaceholder": "كلمة المرور",
|
||||||
|
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة."
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"owner": "المالك",
|
"owner": "المالك",
|
||||||
@@ -386,7 +393,8 @@
|
|||||||
"attendance": "الحضور",
|
"attendance": "الحضور",
|
||||||
"leave": "الإجازة",
|
"leave": "الإجازة",
|
||||||
"payroll": "الرواتب",
|
"payroll": "الرواتب",
|
||||||
"access": "صلاحيات الفروع"
|
"access": "صلاحيات الفروع",
|
||||||
|
"credentials": "بيانات الدخول"
|
||||||
},
|
},
|
||||||
"myAttendance": "حضوري",
|
"myAttendance": "حضوري",
|
||||||
"clockIn": "تسجيل دخول",
|
"clockIn": "تسجيل دخول",
|
||||||
@@ -396,7 +404,22 @@
|
|||||||
"paid": "مدفوع",
|
"paid": "مدفوع",
|
||||||
"markPaid": "تسجيل الدفع",
|
"markPaid": "تسجيل الدفع",
|
||||||
"employeeCount": "الموظفون",
|
"employeeCount": "الموظفون",
|
||||||
"monthYear": "شهر الرواتب"
|
"monthYear": "شهر الرواتب",
|
||||||
|
"credentials": {
|
||||||
|
"title": "بيانات دخول الموظفين",
|
||||||
|
"subtitle": "حدد اسم مستخدم وكلمة مرور لكل موظف حتى يتمكن من تسجيل الدخول دون رمز OTP.",
|
||||||
|
"selectEmployee": "اختر موظفاً أولاً",
|
||||||
|
"username": "اسم المستخدم",
|
||||||
|
"usernamePlaceholder": "مثال: ali_barista",
|
||||||
|
"password": "كلمة المرور (8 أحرف على الأقل)",
|
||||||
|
"passwordPlaceholder": "كلمة مرور جديدة",
|
||||||
|
"set": "حفظ بيانات الدخول",
|
||||||
|
"remove": "حذف بيانات الدخول",
|
||||||
|
"removeConfirm": "هل أنت متأكد؟ لن يتمكن الموظف من تسجيل الدخول بكلمة مرور بعد الآن.",
|
||||||
|
"saved": "تم حفظ بيانات الدخول.",
|
||||||
|
"removed": "تم حذف بيانات الدخول.",
|
||||||
|
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reviews": {
|
"reviews": {
|
||||||
"title": "تقييمات العملاء",
|
"title": "تقييمات العملاء",
|
||||||
|
|||||||
@@ -56,7 +56,14 @@
|
|||||||
"chooseCafe": "Choose a café",
|
"chooseCafe": "Choose a café",
|
||||||
"chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.",
|
"chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.",
|
||||||
"createNewCafe": "Create a new café",
|
"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": {
|
"roles": {
|
||||||
"owner": "Owner",
|
"owner": "Owner",
|
||||||
@@ -405,7 +412,8 @@
|
|||||||
"attendance": "Attendance",
|
"attendance": "Attendance",
|
||||||
"leave": "Leave",
|
"leave": "Leave",
|
||||||
"payroll": "Payroll",
|
"payroll": "Payroll",
|
||||||
"access": "Branch access"
|
"access": "Branch access",
|
||||||
|
"credentials": "Login credentials"
|
||||||
},
|
},
|
||||||
"myAttendance": "My attendance",
|
"myAttendance": "My attendance",
|
||||||
"clockIn": "Clock in",
|
"clockIn": "Clock in",
|
||||||
@@ -415,7 +423,22 @@
|
|||||||
"paid": "Paid",
|
"paid": "Paid",
|
||||||
"markPaid": "Mark paid",
|
"markPaid": "Mark paid",
|
||||||
"employeeCount": "Employees",
|
"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": {
|
"reviews": {
|
||||||
"title": "Customer reviews",
|
"title": "Customer reviews",
|
||||||
|
|||||||
@@ -56,7 +56,14 @@
|
|||||||
"chooseCafe": "انتخاب کافه",
|
"chooseCafe": "انتخاب کافه",
|
||||||
"chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.",
|
"chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.",
|
||||||
"createNewCafe": "ایجاد کافه جدید",
|
"createNewCafe": "ایجاد کافه جدید",
|
||||||
"createNewCafeHint": "میخواهید کافه خودتان را با همین شماره راهاندازی کنید؟"
|
"createNewCafeHint": "میخواهید کافه خودتان را با همین شماره راهاندازی کنید؟",
|
||||||
|
"tabOtp": "کد یکبارمصرف",
|
||||||
|
"tabPassword": "رمز عبور",
|
||||||
|
"username": "نام کاربری",
|
||||||
|
"usernamePlaceholder": "نام کاربری",
|
||||||
|
"password": "رمز عبور",
|
||||||
|
"passwordPlaceholder": "رمز عبور",
|
||||||
|
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است."
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"owner": "مالک",
|
"owner": "مالک",
|
||||||
@@ -405,7 +412,8 @@
|
|||||||
"attendance": "حضور و غیاب",
|
"attendance": "حضور و غیاب",
|
||||||
"leave": "مرخصی",
|
"leave": "مرخصی",
|
||||||
"payroll": "حقوق",
|
"payroll": "حقوق",
|
||||||
"access": "دسترسی شعب"
|
"access": "دسترسی شعب",
|
||||||
|
"credentials": "رمز ورود"
|
||||||
},
|
},
|
||||||
"myAttendance": "حضور من",
|
"myAttendance": "حضور من",
|
||||||
"clockIn": "ورود",
|
"clockIn": "ورود",
|
||||||
@@ -415,7 +423,22 @@
|
|||||||
"paid": "پرداخت شده",
|
"paid": "پرداخت شده",
|
||||||
"markPaid": "ثبت پرداخت",
|
"markPaid": "ثبت پرداخت",
|
||||||
"employeeCount": "تعداد کارمندان",
|
"employeeCount": "تعداد کارمندان",
|
||||||
"monthYear": "ماه حقوق"
|
"monthYear": "ماه حقوق",
|
||||||
|
"credentials": {
|
||||||
|
"title": "مدیریت رمز ورود کارمندان",
|
||||||
|
"subtitle": "برای هر کارمند میتوانید نام کاربری و رمز عبور تعریف کنید تا بدون نیاز به کد OTP وارد شوند.",
|
||||||
|
"selectEmployee": "ابتدا یک کارمند انتخاب کنید",
|
||||||
|
"username": "نام کاربری",
|
||||||
|
"usernamePlaceholder": "مثال: ali_barista",
|
||||||
|
"password": "رمز عبور (حداقل ۸ کاراکتر)",
|
||||||
|
"passwordPlaceholder": "رمز عبور جدید",
|
||||||
|
"set": "ذخیره رمز ورود",
|
||||||
|
"remove": "حذف رمز ورود",
|
||||||
|
"removeConfirm": "آیا مطمئنید؟ کارمند دیگر نمیتواند با رمز عبور وارد شود.",
|
||||||
|
"saved": "رمز ورود ذخیره شد.",
|
||||||
|
"removed": "رمز ورود حذف شد.",
|
||||||
|
"usernameTaken": "این نام کاربری قبلاً استفاده شده است."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reviews": {
|
"reviews": {
|
||||||
"title": "نظرات مشتریان",
|
"title": "نظرات مشتریان",
|
||||||
|
|||||||
@@ -12,14 +12,24 @@ import { LabeledField } from "@/components/ui/labeled-field";
|
|||||||
import { OtpInput } from "@/components/ui/otp-input";
|
import { OtpInput } from "@/components/ui/otp-input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
type LoginTab = "otp" | "password";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<LoginTab>("otp");
|
||||||
|
|
||||||
|
// OTP state
|
||||||
const [phone, setPhone] = useState("09121234567");
|
const [phone, setPhone] = useState("09121234567");
|
||||||
const [code, setCode] = useState("");
|
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 [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -32,6 +42,9 @@ export default function LoginPage() {
|
|||||||
return t("smsFailed");
|
return t("smsFailed");
|
||||||
case "INVALID_OTP":
|
case "INVALID_OTP":
|
||||||
return t("invalidOtp");
|
return t("invalidOtp");
|
||||||
|
case "INVALID_TOKEN":
|
||||||
|
case "NOT_FOUND":
|
||||||
|
return tab === "password" ? t("invalidCredentials") : t("notFound");
|
||||||
default:
|
default:
|
||||||
return err.message;
|
return err.message;
|
||||||
}
|
}
|
||||||
@@ -44,7 +57,7 @@ export default function LoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await apiPost("/api/auth/send-otp", { phone });
|
await apiPost("/api/auth/send-otp", { phone });
|
||||||
setStep("otp");
|
setOtpStep("otp");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiClientError && e.code === "NOT_FOUND") {
|
if (e instanceof ApiClientError && e.code === "NOT_FOUND") {
|
||||||
// No account → take them to register with phone pre-filled
|
// 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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
@@ -81,8 +122,36 @@ export default function LoginPage() {
|
|||||||
<CardTitle className="text-center text-primary">{t("title")}</CardTitle>
|
<CardTitle className="text-center text-primary">{t("title")}</CardTitle>
|
||||||
<p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p>
|
<p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||||
</CardHeader>
|
</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
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -105,7 +174,9 @@ export default function LoginPage() {
|
|||||||
{loading ? "..." : t("sendOtp")}
|
{loading ? "..." : t("sendOtp")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{tab === "otp" && otpStep === "otp" && (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -128,12 +199,60 @@ export default function LoginPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => setStep("phone")}
|
onClick={() => {
|
||||||
|
setOtpStep("phone");
|
||||||
|
setCode("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("resend")}
|
{t("resend")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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 && (
|
{error && (
|
||||||
<p className="text-center text-sm text-destructive">{error}</p>
|
<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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
|
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
|
||||||
|
import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel";
|
||||||
|
|
||||||
interface Employee {
|
interface Employee {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -47,7 +48,7 @@ interface Salary {
|
|||||||
isPaid: boolean;
|
isPaid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = "attendance" | "leave" | "payroll" | "access";
|
type Tab = "attendance" | "leave" | "payroll" | "access" | "credentials";
|
||||||
|
|
||||||
export function HrScreen() {
|
export function HrScreen() {
|
||||||
const t = useTranslations("hr");
|
const t = useTranslations("hr");
|
||||||
@@ -122,8 +123,8 @@ export function HrScreen() {
|
|||||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{((["attendance", "leave", "payroll", "access"] as Tab[]).filter(
|
{((["attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
|
||||||
(key) => key !== "access" || canManageAccess
|
(key) => (key !== "access" && key !== "credentials") || canManageAccess
|
||||||
)).map((key) => (
|
)).map((key) => (
|
||||||
<Button
|
<Button
|
||||||
key={key}
|
key={key}
|
||||||
@@ -230,6 +231,10 @@ export function HrScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "access" && canManageAccess && <BranchAccessPanel cafeId={cafeId} />}
|
{tab === "access" && canManageAccess && <BranchAccessPanel cafeId={cafeId} />}
|
||||||
|
|
||||||
|
{tab === "credentials" && canManageAccess && (
|
||||||
|
<EmployeeCredentialsPanel cafeId={cafeId} employees={employees} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useLocale } from "next-intl";
|
|||||||
import { useRouter } from "@/i18n/routing";
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { Clock, X, Zap } from "lucide-react";
|
import { Clock, X, Zap } from "lucide-react";
|
||||||
|
|
||||||
// 14 Khordad 1405 = June 4, 2026 (Tehran UTC+3:30)
|
// 1 Tir 1405 = June 22, 2026 (Tehran IRDT UTC+4:30)
|
||||||
const DEADLINE = new Date("2026-06-04T00:00:00+03:30");
|
const DEADLINE = new Date("2026-06-22T00:00:00+04:30");
|
||||||
const STORAGE_KEY = "meezi_trial_banner_v1";
|
const STORAGE_KEY = "meezi_trial_banner_v1";
|
||||||
|
|
||||||
interface TimeLeft {
|
interface TimeLeft {
|
||||||
@@ -78,11 +78,11 @@ export function TrialCountdownBanner() {
|
|||||||
|
|
||||||
const textFa = expired
|
const textFa = expired
|
||||||
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
|
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
|
||||||
: "دوره آزمایشی رایگان تا ۱۴ خرداد ۱۴۰۵";
|
: "دوره آزمایشی رایگان تا ۱ تیر ۱۴۰۵";
|
||||||
|
|
||||||
const textEn = expired
|
const textEn = expired
|
||||||
? "Your Meezi trial has ended. Choose a plan to continue."
|
? "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 }) => (
|
const Digit = ({ value, label }: { value: number; label: string }) => (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
|
|||||||
Reference in New Issue
Block a user