feat(auth): admin-issued café recovery key login
CI/CD / CI · API (dotnet build + test) (push) Successful in 5m6s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 1m0s
CI/CD / Deploy · all services (push) Successful in 5m31s
CI/CD / CI · API (dotnet build + test) (push) Successful in 5m6s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 1m0s
CI/CD / Deploy · all services (push) Successful in 5m31s
Platform admins can generate a permanent recovery key per café (admin
panel → Cafés). The café Owner uses it to sign in when OTP access is lost;
once authenticated, all server-side data syncs as normal (data is per-café
on the server, the device only caches it).
Backend:
- Cafe.RecoveryKeyHash (SHA-256, unique index) + RecoveryKeyCreatedAt; migration
- RecoveryKeyGenerator util: MZ-XXXXX-XXXXX-XXXXX-XXXXX, ~190-bit entropy,
stored as SHA-256 (API-token pattern — raw key shown once, never retrievable)
- Admin: POST/DELETE /api/admin/cafes/{id}/recovery-key (key returned once);
café list now reports HasRecoveryKey + RecoveryKeyCreatedAt
- Login: POST /api/auth/login-key → exact-hash lookup → resolves café Owner →
issues normal JWT; rate-limited (auth-otp), suspended/no-owner guarded, logged
Admin UI: per-café generate / regenerate / revoke with one-time reveal + copy.
Dashboard login: discreet "ورود با کلید بازیابی" link → key field. fa/en/ar.
86 tests pass; all tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,23 @@ public class AuthController : ControllerBase
|
|||||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("login-key")]
|
||||||
|
[EnableRateLimiting("auth-otp")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> LoginWithRecoveryKey(
|
||||||
|
[FromBody] LoginWithRecoveryKeyRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Key))
|
||||||
|
return BadRequest(ValidationError("Recovery key is required."));
|
||||||
|
|
||||||
|
var (success, data, code, message) = await _authService.LoginWithRecoveryKeyAsync(request, cancellationToken);
|
||||||
|
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)]
|
||||||
@@ -224,7 +241,9 @@ public class AuthController : ControllerBase
|
|||||||
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
||||||
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
"INVALID_OTP" or "INVALID_TOKEN" or "INVALID_KEY" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
|
"CAFE_SUSPENDED" or "NO_OWNER" => StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"BRANCH_FORBIDDEN" => StatusCode(StatusCodes.Status403Forbidden,
|
"BRANCH_FORBIDDEN" => StatusCode(StatusCodes.Status403Forbidden,
|
||||||
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ public record SendOtpRequest(string Phone);
|
|||||||
/// <summary>Username + password login (alternative to OTP). Optional cafeId to scope to a specific café.</summary>
|
/// <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 LoginWithPasswordRequest(string Username, string Password, string? CafeId = null);
|
||||||
|
|
||||||
|
/// <summary>Admin-issued recovery key login — logs the café Owner in when OTP access is lost.</summary>
|
||||||
|
public record LoginWithRecoveryKeyRequest(string Key);
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -509,6 +509,45 @@ public class AuthService : IAuthService
|
|||||||
return (true, tokens, null, null, null);
|
return (true, tokens, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithRecoveryKeyAsync(
|
||||||
|
LoginWithRecoveryKeyRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Key))
|
||||||
|
return (false, null, "INVALID_KEY", "Invalid recovery key.");
|
||||||
|
|
||||||
|
var hash = RecoveryKeyGenerator.HashOf(request.Key);
|
||||||
|
|
||||||
|
// Exact-hash lookup — the unique index makes this a single index seek.
|
||||||
|
var cafe = await _db.Cafes
|
||||||
|
.FirstOrDefaultAsync(c => c.RecoveryKeyHash == hash && c.DeletedAt == null, cancellationToken);
|
||||||
|
if (cafe is null)
|
||||||
|
return (false, null, "INVALID_KEY", "Invalid recovery key.");
|
||||||
|
|
||||||
|
if (cafe.IsSuspended)
|
||||||
|
return (false, null, "CAFE_SUSPENDED", "This café is suspended. Contact support.");
|
||||||
|
|
||||||
|
// The key authenticates as the café's Owner.
|
||||||
|
var owner = await _db.Employees
|
||||||
|
.Include(e => e.Cafe)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
e => e.CafeId == cafe.Id && e.Role == EmployeeRole.Owner && e.DeletedAt == null,
|
||||||
|
cancellationToken);
|
||||||
|
if (owner?.Cafe is null)
|
||||||
|
return (false, null, "NO_OWNER", "This café has no owner account.");
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Recovery-key login for café {CafeId} as owner {OwnerId}", cafe.Id, owner.Id);
|
||||||
|
|
||||||
|
var membershipDtos = new List<CafeMembershipDto>
|
||||||
|
{
|
||||||
|
new(owner.CafeId, owner.Cafe.Name, owner.Role.ToString(), owner.Cafe.PlanTier.ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokens = await IssueTokensAsync(owner, owner.Cafe, membershipDtos, null, cancellationToken);
|
||||||
|
return (true, tokens, 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,
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ public interface IAuthService
|
|||||||
LoginWithPasswordRequest request,
|
LoginWithPasswordRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>Log in the café Owner using an admin-issued permanent recovery key.</summary>
|
||||||
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithRecoveryKeyAsync(
|
||||||
|
LoginWithRecoveryKeyRequest 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);
|
||||||
|
|||||||
@@ -45,6 +45,36 @@ public class AdminCafesController : AdminApiControllerBase
|
|||||||
return Ok(new ApiResponse<object>(true, new { cafeId, request.FeatureKey, request.IsEnabled }));
|
return Ok(new ApiResponse<object>(true, new { cafeId, request.FeatureKey, request.IsEnabled }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Generate (or regenerate) a permanent recovery key for the café's
|
||||||
|
/// Owner. The raw key is returned ONCE — only its hash is stored.</summary>
|
||||||
|
[HttpPost("{cafeId}/recovery-key")]
|
||||||
|
public async Task<IActionResult> GenerateRecoveryKey(string cafeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (ok, key, code) = await _platform.GenerateRecoveryKeyAsync(cafeId, cancellationToken);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
"NO_OWNER" => BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("NO_OWNER", "This café has no owner account to attach a recovery key to."))),
|
||||||
|
_ => NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, new { cafeId, key }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Revoke the café's recovery key (clears the stored hash).</summary>
|
||||||
|
[HttpDelete("{cafeId}/recovery-key")]
|
||||||
|
public async Task<IActionResult> RevokeRecoveryKey(string cafeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ok = await _platform.RevokeRecoveryKeyAsync(cafeId, cancellationToken);
|
||||||
|
if (!ok)
|
||||||
|
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, new { cafeId }));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{cafeId}/discover-profile")]
|
[HttpGet("{cafeId}/discover-profile")]
|
||||||
public async Task<IActionResult> GetDiscoverProfile(string cafeId, CancellationToken cancellationToken)
|
public async Task<IActionResult> GetDiscoverProfile(string cafeId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ public record AdminCafeListItemDto(
|
|||||||
bool IsVerified,
|
bool IsVerified,
|
||||||
int BranchCount,
|
int BranchCount,
|
||||||
int EmployeeCount,
|
int EmployeeCount,
|
||||||
DateTime CreatedAt);
|
DateTime CreatedAt,
|
||||||
|
bool HasRecoveryKey,
|
||||||
|
DateTime? RecoveryKeyCreatedAt);
|
||||||
|
|
||||||
public record AdminCafePatchRequest(
|
public record AdminCafePatchRequest(
|
||||||
PlanTier? PlanTier,
|
PlanTier? PlanTier,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Meezi.Core.Entities;
|
|||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Core.Platform;
|
using Meezi.Core.Platform;
|
||||||
|
using Meezi.Core.Utilities;
|
||||||
using Meezi.Core.Discover;
|
using Meezi.Core.Discover;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Infrastructure.Discover;
|
using Meezi.Infrastructure.Discover;
|
||||||
@@ -23,6 +24,8 @@ public interface IAdminPlatformService
|
|||||||
Task<bool> UpdateFeatureAsync(string featureKey, UpdateFeatureRequest request, CancellationToken cancellationToken = default);
|
Task<bool> UpdateFeatureAsync(string featureKey, UpdateFeatureRequest request, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyList<AdminCafeListItemDto>> ListCafesAsync(CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<AdminCafeListItemDto>> ListCafesAsync(CancellationToken cancellationToken = default);
|
||||||
Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default);
|
Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default);
|
||||||
|
Task<(bool Success, string? Key, string? ErrorCode)> GenerateRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> RevokeRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||||
Task<bool> SetCafeFeatureOverrideAsync(string cafeId, CafeFeatureOverrideRequest request, CancellationToken cancellationToken = default);
|
Task<bool> SetCafeFeatureOverrideAsync(string cafeId, CafeFeatureOverrideRequest request, CancellationToken cancellationToken = default);
|
||||||
Task<AdminCafeDiscoverProfileDto?> GetCafeDiscoverProfileAsync(string cafeId, CancellationToken cancellationToken = default);
|
Task<AdminCafeDiscoverProfileDto?> GetCafeDiscoverProfileAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||||
Task<AdminCafeDiscoverProfileDto?> UpsertCafeDiscoverProfileAsync(
|
Task<AdminCafeDiscoverProfileDto?> UpsertCafeDiscoverProfileAsync(
|
||||||
@@ -146,10 +149,44 @@ public class AdminPlatformService : IAdminPlatformService
|
|||||||
c.IsVerified,
|
c.IsVerified,
|
||||||
c.Branches.Count,
|
c.Branches.Count,
|
||||||
c.Employees.Count(e => e.DeletedAt == null),
|
c.Employees.Count(e => e.DeletedAt == null),
|
||||||
c.CreatedAt))
|
c.CreatedAt,
|
||||||
|
c.RecoveryKeyHash != null,
|
||||||
|
c.RecoveryKeyCreatedAt))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, string? Key, string? ErrorCode)> GenerateRecoveryKeyAsync(
|
||||||
|
string cafeId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||||
|
if (cafe is null) return (false, null, "NOT_FOUND");
|
||||||
|
|
||||||
|
// The key logs in as the café's Owner, so refuse to mint one with no owner.
|
||||||
|
var hasOwner = await _db.Employees.AnyAsync(
|
||||||
|
e => e.CafeId == cafeId && e.Role == EmployeeRole.Owner && e.DeletedAt == null,
|
||||||
|
cancellationToken);
|
||||||
|
if (!hasOwner) return (false, null, "NO_OWNER");
|
||||||
|
|
||||||
|
var (rawKey, hash) = RecoveryKeyGenerator.Generate();
|
||||||
|
cafe.RecoveryKeyHash = hash; // replaces any previous key (revokes old)
|
||||||
|
cafe.RecoveryKeyCreatedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return (true, rawKey, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RevokeRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||||
|
if (cafe is null) return false;
|
||||||
|
|
||||||
|
cafe.RecoveryKeyHash = null;
|
||||||
|
cafe.RecoveryKeyCreatedAt = null;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default)
|
public async Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ public class Cafe : BaseEntity
|
|||||||
/// <summary>Café's own SMS sender line number (e.g. 10004346).</summary>
|
/// <summary>Café's own SMS sender line number (e.g. 10004346).</summary>
|
||||||
public string? SmsSenderNumber { get; set; }
|
public string? SmsSenderNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>SHA-256 hex of an admin-generated permanent recovery key. When set,
|
||||||
|
/// the café Owner can log in with the raw key if they lose OTP access. Null = no
|
||||||
|
/// key issued. Cleared when the platform admin revokes it.</summary>
|
||||||
|
public string? RecoveryKeyHash { get; set; }
|
||||||
|
/// <summary>When the current recovery key was generated (for admin display).</summary>
|
||||||
|
public DateTime? RecoveryKeyCreatedAt { get; set; }
|
||||||
|
|
||||||
public ICollection<Branch> Branches { get; set; } = [];
|
public ICollection<Branch> Branches { get; set; } = [];
|
||||||
public ICollection<Table> Tables { get; set; } = [];
|
public ICollection<Table> Tables { get; set; } = [];
|
||||||
public ICollection<Employee> Employees { get; set; } = [];
|
public ICollection<Employee> Employees { get; set; } = [];
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Meezi.Core.Utilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin-generated café recovery keys. The raw key is shown to the admin exactly
|
||||||
|
/// once; only its SHA-256 hash is stored, looked up at login by exact hash match.
|
||||||
|
/// A salt is unnecessary (and would break lookup) because the key carries ~190
|
||||||
|
/// bits of entropy — the same model password-manager / API-token systems use.
|
||||||
|
/// </summary>
|
||||||
|
public static class RecoveryKeyGenerator
|
||||||
|
{
|
||||||
|
// Crockford-ish base32 alphabet, no easily-confused chars (0/O, 1/I/L).
|
||||||
|
private const string Alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||||
|
private const int GroupCount = 4;
|
||||||
|
private const int GroupSize = 5;
|
||||||
|
|
||||||
|
/// <summary>Make a fresh key. Returns the raw key (give to the owner) and its
|
||||||
|
/// hash (store on the café). Format: <c>MZ-XXXXX-XXXXX-XXXXX-XXXXX</c>.</summary>
|
||||||
|
public static (string RawKey, string Hash) Generate()
|
||||||
|
{
|
||||||
|
var chars = new char[GroupCount * GroupSize];
|
||||||
|
for (var i = 0; i < chars.Length; i++)
|
||||||
|
chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
|
||||||
|
|
||||||
|
var groups = new string[GroupCount];
|
||||||
|
for (var g = 0; g < GroupCount; g++)
|
||||||
|
groups[g] = new string(chars, g * GroupSize, GroupSize);
|
||||||
|
|
||||||
|
var rawKey = "MZ-" + string.Join("-", groups);
|
||||||
|
return (rawKey, HashOf(rawKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SHA-256 hex of a normalized key, for storage and exact-match lookup.</summary>
|
||||||
|
public static string HashOf(string rawKey)
|
||||||
|
{
|
||||||
|
var normalized = Normalize(rawKey);
|
||||||
|
var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(normalized));
|
||||||
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Uppercase, trim, and collapse spaces so a hand-typed key still matches.</summary>
|
||||||
|
public static string Normalize(string rawKey) =>
|
||||||
|
rawKey.Trim().ToUpperInvariant().Replace(" ", "");
|
||||||
|
}
|
||||||
@@ -134,6 +134,10 @@ public class AppDbContext : DbContext
|
|||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
e.HasIndex(x => x.Slug).IsUnique();
|
e.HasIndex(x => x.Slug).IsUnique();
|
||||||
|
// Recovery-key login looks up the café by exact hash; Postgres treats
|
||||||
|
// NULLs as distinct so many cafés without a key coexist fine.
|
||||||
|
e.HasIndex(x => x.RecoveryKeyHash).IsUnique();
|
||||||
|
e.Property(x => x.RecoveryKeyHash).HasMaxLength(64);
|
||||||
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||||
e.Property(x => x.Slug).HasMaxLength(100).IsRequired();
|
e.Property(x => x.Slug).HasMaxLength(100).IsRequired();
|
||||||
e.Property(x => x.SnappfoodVendorId).HasMaxLength(100);
|
e.Property(x => x.SnappfoodVendorId).HasMaxLength(100);
|
||||||
|
|||||||
+3429
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCafeRecoveryKey : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "RecoveryKeyCreatedAt",
|
||||||
|
table: "Cafes",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "RecoveryKeyHash",
|
||||||
|
table: "Cafes",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Cafes_RecoveryKeyHash",
|
||||||
|
table: "Cafes",
|
||||||
|
column: "RecoveryKeyHash",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Cafes_RecoveryKeyHash",
|
||||||
|
table: "Cafes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RecoveryKeyCreatedAt",
|
||||||
|
table: "Cafes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RecoveryKeyHash",
|
||||||
|
table: "Cafes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -360,6 +360,13 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RecoveryKeyCreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("RecoveryKeyHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
b.Property<bool>("ShowOnKoja")
|
b.Property<bool>("ShowOnKoja")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
@@ -396,6 +403,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RecoveryKeyHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("Slug")
|
b.HasIndex("Slug")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
|||||||
@@ -493,6 +493,7 @@ export function AdminCafesScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<RecoveryKeyPanel cafe={c} />
|
||||||
{profileCafeId === c.id ? (
|
{profileCafeId === c.id ? (
|
||||||
<CafeDiscoverProfilePanel cafeId={c.id} mode="admin" compact />
|
<CafeDiscoverProfilePanel cafeId={c.id} mode="admin" compact />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -503,6 +504,93 @@ export function AdminCafesScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate / revoke a café's permanent recovery key. The raw key is returned
|
||||||
|
* once on generate — shown here for copy, never retrievable again.
|
||||||
|
*/
|
||||||
|
function RecoveryKeyPanel({ cafe }: { cafe: AdminCafe }) {
|
||||||
|
const t = useTranslations("admin.cafes.recoveryKey");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [revealed, setRevealed] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const generate = useMutation({
|
||||||
|
mutationFn: () => adminPost<{ cafeId: string; key: string }>(`/api/admin/cafes/${cafe.id}/recovery-key`),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setRevealed(data.key);
|
||||||
|
setCopied(false);
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin", "cafes"] });
|
||||||
|
},
|
||||||
|
onError: () => notify.error(t("generateFailed")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const revoke = useMutation({
|
||||||
|
mutationFn: () => adminDelete(`/api/admin/cafes/${cafe.id}/recovery-key`),
|
||||||
|
onSuccess: () => {
|
||||||
|
setRevealed(null);
|
||||||
|
notify.success(t("revoked"));
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin", "cafes"] });
|
||||||
|
},
|
||||||
|
onError: () => notify.error(t("revokeFailed")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
if (!revealed) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(revealed);
|
||||||
|
setCopied(true);
|
||||||
|
} catch {
|
||||||
|
/* clipboard blocked — user can still select the text */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/70 bg-muted/20 p-3 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t("title")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{cafe.hasRecoveryKey ? t("active") : t("none")}
|
||||||
|
{cafe.hasRecoveryKey && cafe.recoveryKeyCreatedAt
|
||||||
|
? ` · ${new Date(cafe.recoveryKeyCreatedAt).toLocaleDateString("fa-IR")}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => generate.mutate()} disabled={generate.isPending}>
|
||||||
|
{cafe.hasRecoveryKey ? t("regenerate") : t("generate")}
|
||||||
|
</Button>
|
||||||
|
{cafe.hasRecoveryKey ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-destructive text-destructive hover:bg-destructive/10"
|
||||||
|
onClick={() => revoke.mutate()}
|
||||||
|
disabled={revoke.isPending}
|
||||||
|
>
|
||||||
|
{t("revoke")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{revealed ? (
|
||||||
|
<div className="mt-3 space-y-2 rounded-lg border border-primary/30 bg-primary/5 p-3">
|
||||||
|
<p className="text-xs font-medium text-primary">{t("revealHint")}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 select-all rounded bg-background px-3 py-2 font-mono text-base tracking-wider" dir="ltr">
|
||||||
|
{revealed}
|
||||||
|
</code>
|
||||||
|
<Button size="sm" onClick={() => void copy()}>
|
||||||
|
{copied ? t("copied") : t("copy")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminTicketsScreen() {
|
export function AdminTicketsScreen() {
|
||||||
const t = useTranslations("admin.tickets");
|
const t = useTranslations("admin.tickets");
|
||||||
const [filter, setFilter] = useState<"all" | "open" | "closed">("all");
|
const [filter, setFilter] = useState<"all" | "open" | "closed">("all");
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export type AdminCafe = {
|
|||||||
branchCount: number;
|
branchCount: number;
|
||||||
employeeCount: number;
|
employeeCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
hasRecoveryKey: boolean;
|
||||||
|
recoveryKeyCreatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SupportTicket = {
|
export type SupportTicket = {
|
||||||
|
|||||||
@@ -61,6 +61,11 @@
|
|||||||
"password": "كلمة المرور",
|
"password": "كلمة المرور",
|
||||||
"passwordPlaceholder": "كلمة المرور",
|
"passwordPlaceholder": "كلمة المرور",
|
||||||
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة.",
|
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة.",
|
||||||
|
"invalidKey": "مفتاح الاستعادة غير صالح.",
|
||||||
|
"recoveryKey": "مفتاح الاستعادة",
|
||||||
|
"keyHint": "أدخل مفتاح الاستعادة الذي حصلت عليه من دعم ميزي.",
|
||||||
|
"useRecoveryKey": "فقدت الوصول؟ سجّل الدخول بمفتاح الاستعادة",
|
||||||
|
"backToNormalLogin": "العودة إلى تسجيل الدخول العادي",
|
||||||
"kojaSlug": "عنوان الملف الشخصي في كوجا",
|
"kojaSlug": "عنوان الملف الشخصي في كوجا",
|
||||||
"kojaSlugHint": "يجد الزوار مقهاكم على هذا العنوان",
|
"kojaSlugHint": "يجد الزوار مقهاكم على هذا العنوان",
|
||||||
"kojaSlugPlaceholder": "مثال: my-cafe"
|
"kojaSlugPlaceholder": "مثال: my-cafe"
|
||||||
|
|||||||
@@ -72,6 +72,11 @@
|
|||||||
"password": "Password",
|
"password": "Password",
|
||||||
"passwordPlaceholder": "Password",
|
"passwordPlaceholder": "Password",
|
||||||
"invalidCredentials": "Incorrect username or password.",
|
"invalidCredentials": "Incorrect username or password.",
|
||||||
|
"invalidKey": "Invalid recovery key.",
|
||||||
|
"recoveryKey": "Recovery key",
|
||||||
|
"keyHint": "Enter the recovery key you received from Meezi support.",
|
||||||
|
"useRecoveryKey": "Lost access? Sign in with a recovery key",
|
||||||
|
"backToNormalLogin": "Back to normal sign-in",
|
||||||
"kojaSlug": "Koja profile address",
|
"kojaSlug": "Koja profile address",
|
||||||
"kojaSlugHint": "Customers will find your cafe at this address",
|
"kojaSlugHint": "Customers will find your cafe at this address",
|
||||||
"kojaSlugPlaceholder": "e.g. my-cafe"
|
"kojaSlugPlaceholder": "e.g. my-cafe"
|
||||||
|
|||||||
@@ -72,6 +72,11 @@
|
|||||||
"password": "رمز عبور",
|
"password": "رمز عبور",
|
||||||
"passwordPlaceholder": "رمز عبور",
|
"passwordPlaceholder": "رمز عبور",
|
||||||
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است.",
|
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است.",
|
||||||
|
"invalidKey": "کلید بازیابی نامعتبر است.",
|
||||||
|
"recoveryKey": "کلید بازیابی",
|
||||||
|
"keyHint": "کلید بازیابی را که از پشتیبانی میزی دریافت کردهاید وارد کنید.",
|
||||||
|
"useRecoveryKey": "دسترسی ندارید؟ ورود با کلید بازیابی",
|
||||||
|
"backToNormalLogin": "بازگشت به ورود عادی",
|
||||||
"kojaSlug": "آدرس پروفایل در کوجا",
|
"kojaSlug": "آدرس پروفایل در کوجا",
|
||||||
"kojaSlugHint": "بازدیدکنندگان از این آدرس کافه شما را پیدا میکنند",
|
"kojaSlugHint": "بازدیدکنندگان از این آدرس کافه شما را پیدا میکنند",
|
||||||
"kojaSlugPlaceholder": "مثال: cafe-roya"
|
"kojaSlugPlaceholder": "مثال: cafe-roya"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ 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";
|
type LoginTab = "otp" | "password" | "key";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
@@ -30,6 +30,9 @@ export default function LoginPage() {
|
|||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
// Recovery-key state (admin-issued key when OTP access is lost)
|
||||||
|
const [recoveryKey, setRecoveryKey] = useState("");
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -42,6 +45,8 @@ export default function LoginPage() {
|
|||||||
return t("smsFailed");
|
return t("smsFailed");
|
||||||
case "INVALID_OTP":
|
case "INVALID_OTP":
|
||||||
return t("invalidOtp");
|
return t("invalidOtp");
|
||||||
|
case "INVALID_KEY":
|
||||||
|
return t("invalidKey");
|
||||||
case "INVALID_TOKEN":
|
case "INVALID_TOKEN":
|
||||||
case "NOT_FOUND":
|
case "NOT_FOUND":
|
||||||
return tab === "password" ? t("invalidCredentials") : t("notFound");
|
return tab === "password" ? t("invalidCredentials") : t("notFound");
|
||||||
@@ -108,6 +113,26 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loginWithRecoveryKey = async () => {
|
||||||
|
if (!recoveryKey.trim()) {
|
||||||
|
setError(t("invalidKey"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await apiPost<AuthTokenResponse>("/api/auth/login-key", {
|
||||||
|
key: recoveryKey.trim(),
|
||||||
|
});
|
||||||
|
setAuth(data);
|
||||||
|
router.push("/pos");
|
||||||
|
} catch (e) {
|
||||||
|
setError(authErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const switchTab = (next: LoginTab) => {
|
const switchTab = (next: LoginTab) => {
|
||||||
setTab(next);
|
setTab(next);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -253,16 +278,67 @@ export default function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───── Recovery key tab ───── */}
|
||||||
|
{tab === "key" && (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!loading) void loginWithRecoveryKey();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("keyHint")}</p>
|
||||||
|
<LabeledField label={t("recoveryKey")} htmlFor="login-key">
|
||||||
|
<Input
|
||||||
|
id="login-key"
|
||||||
|
value={recoveryKey}
|
||||||
|
onChange={(e) => setRecoveryKey(e.target.value)}
|
||||||
|
placeholder="MZ-XXXXX-XXXXX-XXXXX-XXXXX"
|
||||||
|
dir="ltr"
|
||||||
|
className="text-start font-mono tracking-wider"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading || !recoveryKey.trim()}>
|
||||||
|
{loading ? "..." : t("verify")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-center text-sm text-destructive">{error}</p>
|
<p className="text-center text-sm text-destructive">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "key" ? (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => switchTab("otp")}
|
||||||
|
className="font-medium text-primary hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("backToNormalLogin")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
{t("noAccount")}{" "}
|
{t("noAccount")}{" "}
|
||||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||||
{t("registerLink")}
|
{t("registerLink")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => switchTab("key")}
|
||||||
|
className="hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("useRecoveryKey")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user