Просмотр исходного кода

Reduce sign-in & sign-up friction (#178)

* improved signin behavior

* tabel

* test
Bernt Christian Egeland 4 месяцев назад
Родитель
Сommit
f34341a95d

+ 8 - 3
messages/de/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Zu viele Versuche, bitte versuchen Sie es später erneut.",
       "invalidCredentials": "Ungültige E-Mail-Adresse oder ungültiges Passwort",
       "passkeyFailed": "Passkey-Anmeldung fehlgeschlagen. Versuchen Sie es erneut oder verwenden Sie E-Mail und Passwort."
-    }
+    },
+    "resetPasswordCta": "Hier zurücksetzen"
   },
   "signUp": {
     "title": "Konto erstellen",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Konto konnte nicht erstellt werden",
       "registrationDisabled": "Die Registrierung ist derzeit deaktiviert. Bitte wenden Sie sich an Ihren Administrator.",
-      "invitationFailed": "Einladung konnte nicht angenommen werden"
-    }
+      "invitationFailed": "Einladung konnte nicht angenommen werden",
+      "termsRequired": "Bitte akzeptieren Sie die Nutzungsbedingungen, um fortzufahren",
+      "emailExists": "Ein Konto mit dieser E-Mail-Adresse existiert bereits."
+    },
+    "passwordHint": "Mindestens 8 Zeichen",
+    "signInInstead": "Stattdessen anmelden"
   },
   "forgotPassword": {
     "title": "Passwort zurücksetzen",

+ 6 - 1
messages/en/auth.json

@@ -4,6 +4,7 @@
     "description": "Sign in to your workshop",
     "passwordPlaceholder": "Enter your password",
     "forgotPassword": "Forgot password?",
+    "resetPasswordCta": "Reset it here",
     "noAccount": "Don't have an account?",
     "createOne": "Create one",
     "termsAgreement": "By using this service you agree to our",
@@ -23,14 +24,18 @@
     "fullName": "Full Name",
     "fullNamePlaceholder": "John Doe",
     "passwordPlaceholder": "Create a strong password",
+    "passwordHint": "Must be at least 8 characters",
     "agreeToTerms": "I agree to the",
     "createAccountJoin": "Create Account & Join Team",
     "createAccount": "Create Account",
     "alreadyHaveAccount": "Already have an account?",
+    "signInInstead": "Sign in instead",
     "errors": {
       "couldNotCreate": "Could not create account",
       "registrationDisabled": "Registration is currently disabled. Please contact your administrator.",
-      "invitationFailed": "Failed to accept invitation"
+      "invitationFailed": "Failed to accept invitation",
+      "termsRequired": "Please accept the terms of service to continue",
+      "emailExists": "An account with this email already exists."
     }
   },
   "forgotPassword": {

+ 8 - 3
messages/es/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Demasiados intentos, vuelva a intentarlo mas tarde.",
       "invalidCredentials": "Correo electronico o contrasena no validos",
       "passkeyFailed": "Error al iniciar sesión con passkey. Inténtelo de nuevo o use correo y contraseña."
-    }
+    },
+    "resetPasswordCta": "Restablécela aquí"
   },
   "signUp": {
     "title": "Crear una cuenta",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "No se pudo crear la cuenta",
       "registrationDisabled": "El registro esta deshabilitado actualmente. Por favor, contacte a su administrador.",
-      "invitationFailed": "No se pudo aceptar la invitacion"
-    }
+      "invitationFailed": "No se pudo aceptar la invitacion",
+      "termsRequired": "Acepta los términos del servicio para continuar",
+      "emailExists": "Ya existe una cuenta con este correo electrónico."
+    },
+    "passwordHint": "Debe tener al menos 8 caracteres",
+    "signInInstead": "Inicia sesión en su lugar"
   },
   "forgotPassword": {
     "title": "Restablecer su contrasena",

+ 8 - 3
messages/fr/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Trop de tentatives, veuillez réessayer plus tard.",
       "invalidCredentials": "Adresse e-mail ou mot de passe invalide",
       "passkeyFailed": "Échec de la connexion par passkey. Réessayez ou utilisez l'email et le mot de passe."
-    }
+    },
+    "resetPasswordCta": "Réinitialisez-le ici"
   },
   "signUp": {
     "title": "Créer un compte",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Impossible de créer le compte",
       "registrationDisabled": "L'inscription est actuellement désactivée. Veuillez contacter votre administrateur.",
-      "invitationFailed": "L'acceptation de l'invitation a échoué"
-    }
+      "invitationFailed": "L'acceptation de l'invitation a échoué",
+      "termsRequired": "Veuillez accepter les conditions d'utilisation pour continuer",
+      "emailExists": "Un compte avec cet e-mail existe déjà."
+    },
+    "passwordHint": "Doit comporter au moins 8 caractères",
+    "signInInstead": "Connectez-vous plutôt"
   },
   "forgotPassword": {
     "title": "Réinitialiser votre mot de passe",

+ 8 - 3
messages/it/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Troppi tentativi, riprovi più tardi.",
       "invalidCredentials": "Email o password non validi",
       "passkeyFailed": "Accesso con passkey fallito. Riprova o usa email e password."
-    }
+    },
+    "resetPasswordCta": "Reimpostala qui"
   },
   "signUp": {
     "title": "Crei un account",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Impossibile creare l'account",
       "registrationDisabled": "La registrazione è attualmente disabilitata. La preghiamo di contattare l'amministratore.",
-      "invitationFailed": "Impossibile accettare l'invito"
-    }
+      "invitationFailed": "Impossibile accettare l'invito",
+      "termsRequired": "Accetta i termini di servizio per continuare",
+      "emailExists": "Esiste già un account con questa email."
+    },
+    "passwordHint": "Deve contenere almeno 8 caratteri",
+    "signInInstead": "Accedi invece"
   },
   "forgotPassword": {
     "title": "Reimposti la Sua password",

+ 8 - 3
messages/lt/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Per daug bandymų, pabandykite vėliau.",
       "invalidCredentials": "Neteisingas el. paštas arba slaptažodis",
       "passkeyFailed": "Prisijungimas su prieigos raktu nepavyko. Bandykite dar kartą arba naudokite el. paštą ir slaptažodį."
-    }
+    },
+    "resetPasswordCta": "Iš naujo nustatykite čia"
   },
   "signUp": {
     "title": "Sukurti paskyrą",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Nepavyko sukurti paskyros",
       "registrationDisabled": "Registracija šiuo metu išjungta. Kreipkitės į administratorių.",
-      "invitationFailed": "Nepavyko priimti kvietimo"
-    }
+      "invitationFailed": "Nepavyko priimti kvietimo",
+      "termsRequired": "Norėdami tęsti, sutikite su paslaugų teikimo sąlygomis",
+      "emailExists": "Paskyra su šiuo el. paštu jau egzistuoja."
+    },
+    "passwordHint": "Turi būti bent 8 simboliai",
+    "signInInstead": "Vietoj to prisijunkite"
   },
   "forgotPassword": {
     "title": "Atstatyti slaptažodį",

+ 8 - 3
messages/nb/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "For mange forsøk, prøv igjen senere.",
       "invalidCredentials": "Ugyldig e-post eller passord",
       "passkeyFailed": "Passkey-innlogging mislyktes. Prøv igjen eller bruk e-post og passord."
-    }
+    },
+    "resetPasswordCta": "Tilbakestill det her"
   },
   "signUp": {
     "title": "Opprett en konto",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Kunne ikke opprette konto",
       "registrationDisabled": "Registrering er for øyeblikket deaktivert. Vennligst kontakt administratoren din.",
-      "invitationFailed": "Kunne ikke godta invitasjonen"
-    }
+      "invitationFailed": "Kunne ikke godta invitasjonen",
+      "termsRequired": "Godta vilkårene for tjenesten for å fortsette",
+      "emailExists": "En konto med denne e-postadressen finnes allerede."
+    },
+    "passwordHint": "Må være minst 8 tegn",
+    "signInInstead": "Logg inn i stedet"
   },
   "forgotPassword": {
     "title": "Tilbakestill passordet ditt",

+ 8 - 3
messages/nl/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Te veel pogingen, probeer het later opnieuw.",
       "invalidCredentials": "Ongeldig e-mailadres of wachtwoord",
       "passkeyFailed": "Passkey-aanmelding mislukt. Probeer het opnieuw of gebruik e-mail en wachtwoord."
-    }
+    },
+    "resetPasswordCta": "Stel het hier opnieuw in"
   },
   "signUp": {
     "title": "Account aanmaken",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Kon account niet aanmaken",
       "registrationDisabled": "Registratie is momenteel uitgeschakeld. Neem contact op met uw beheerder.",
-      "invitationFailed": "Uitnodiging accepteren is mislukt"
-    }
+      "invitationFailed": "Uitnodiging accepteren is mislukt",
+      "termsRequired": "Accepteer de servicevoorwaarden om door te gaan",
+      "emailExists": "Er bestaat al een account met dit e-mailadres."
+    },
+    "passwordHint": "Moet minstens 8 tekens bevatten",
+    "signInInstead": "Meld in plaats daarvan aan"
   },
   "forgotPassword": {
     "title": "Wachtwoord herstellen",

+ 8 - 3
messages/pl/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Zbyt wiele prób, spróbuj ponownie później.",
       "invalidCredentials": "Nieprawidłowy e-mail lub hasło",
       "passkeyFailed": "Logowanie za pomocą passkey nie powiodło się. Spróbuj ponownie lub użyj adresu e-mail i hasła."
-    }
+    },
+    "resetPasswordCta": "Zresetuj je tutaj"
   },
   "signUp": {
     "title": "Utwórz konto",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Nie udało się utworzyć konta",
       "registrationDisabled": "Rejestracja jest obecnie wyłączona. Skontaktuj się z administratorem.",
-      "invitationFailed": "Nie udało się przyjąć zaproszenia"
-    }
+      "invitationFailed": "Nie udało się przyjąć zaproszenia",
+      "termsRequired": "Zaakceptuj warunki świadczenia usług, aby kontynuować",
+      "emailExists": "Konto z tym adresem e-mail już istnieje."
+    },
+    "passwordHint": "Musi mieć co najmniej 8 znaków",
+    "signInInstead": "Zaloguj się zamiast tego"
   },
   "forgotPassword": {
     "title": "Zresetuj hasło",

+ 31 - 26
messages/pt-BR/auth.json

@@ -4,41 +4,46 @@
     "description": "Entre na sua oficina",
     "passwordPlaceholder": "Digite sua senha",
     "forgotPassword": "Esqueceu a senha?",
-    "noAccount": "Ainda n\u00e3o tem uma conta?",
+    "noAccount": "Ainda não tem uma conta?",
     "createOne": "Crie uma",
-    "termsAgreement": "Ao utilizar este servi\u00e7o, voc\u00ea concorda com nossos",
+    "termsAgreement": "Ao utilizar este serviço, você concorda com nossos",
     "or": "ou",
     "passkey": "Entrar com Passkey",
     "passkeyDescription": "Use sua impressão digital, rosto ou chave de segurança",
     "errors": {
       "tooManyAttempts": "Muitas tentativas. Tente novamente mais tarde.",
-      "invalidCredentials": "E-mail ou senha inv\u00e1lidos",
+      "invalidCredentials": "E-mail ou senha inválidos",
       "passkeyFailed": "Falha ao entrar com passkey. Tente novamente ou use e-mail e senha."
-    }
+    },
+    "resetPasswordCta": "Redefina aqui"
   },
   "signUp": {
     "title": "Crie sua conta",
     "descriptionInvite": "Crie sua conta para entrar na equipe",
-    "descriptionDefault": "Configure sua oficina para gerenciar os ve\u00edculos dos clientes",
+    "descriptionDefault": "Configure sua oficina para gerenciar os veículos dos clientes",
     "fullName": "Nome Completo",
-    "fullNamePlaceholder": "Jo\u00e3o da Silva",
+    "fullNamePlaceholder": "João da Silva",
     "passwordPlaceholder": "Crie uma senha forte",
     "agreeToTerms": "Eu concordo com os",
     "createAccountJoin": "Criar Conta e Entrar na Equipe",
     "createAccount": "Criar Conta",
-    "alreadyHaveAccount": "J\u00e1 tem uma conta?",
+    "alreadyHaveAccount": "Já tem uma conta?",
     "errors": {
-      "couldNotCreate": "N\u00e3o foi poss\u00edvel criar a conta",
-      "registrationDisabled": "O cadastro est\u00e1 desativado no momento. Entre em contato com o administrador.",
-      "invitationFailed": "Falha ao aceitar o convite"
-    }
+      "couldNotCreate": "Não foi possível criar a conta",
+      "registrationDisabled": "O cadastro está desativado no momento. Entre em contato com o administrador.",
+      "invitationFailed": "Falha ao aceitar o convite",
+      "termsRequired": "Aceite os termos de serviço para continuar",
+      "emailExists": "Já existe uma conta com este e-mail."
+    },
+    "passwordHint": "Deve ter pelo menos 8 caracteres",
+    "signInInstead": "Faça login em vez disso"
   },
   "forgotPassword": {
     "title": "Redefinir sua senha",
-    "description": "Digite seu e-mail e enviaremos um link de redefini\u00e7\u00e3o",
-    "sendResetLink": "Enviar Link de Redefini\u00e7\u00e3o",
+    "description": "Digite seu e-mail e enviaremos um link de redefinição",
+    "sendResetLink": "Enviar Link de Redefinição",
     "backToSignIn": "Voltar para o Login",
-    "successMessage": "Se existir uma conta com esse e-mail, um link de redefini\u00e7\u00e3o de senha foi enviado. Verifique sua caixa de entrada."
+    "successMessage": "Se existir uma conta com esse e-mail, um link de redefinição de senha foi enviado. Verifique sua caixa de entrada."
   },
   "resetPassword": {
     "title": "Definir nova senha",
@@ -50,12 +55,12 @@
     "resetButton": "Redefinir Senha",
     "backToSignIn": "Voltar para o Login",
     "requestNewLink": "Solicitar Novo Link",
-    "invalidToken": "Token de redefini\u00e7\u00e3o inv\u00e1lido ou ausente. Solicite um novo link de redefini\u00e7\u00e3o de senha.",
-    "successMessage": "Sua senha foi redefinida com sucesso. Agora voc\u00ea pode entrar com sua nova senha.",
+    "invalidToken": "Token de redefinição inválido ou ausente. Solicite um novo link de redefinição de senha.",
+    "successMessage": "Sua senha foi redefinida com sucesso. Agora você pode entrar com sua nova senha.",
     "errors": {
-      "passwordsMismatch": "As senhas n\u00e3o coincidem.",
+      "passwordsMismatch": "As senhas não coincidem.",
       "passwordTooShort": "A senha deve ter pelo menos 8 caracteres.",
-      "resetFailed": "N\u00e3o foi poss\u00edvel redefinir a senha. O link pode ter expirado."
+      "resetFailed": "Não foi possível redefinir a senha. O link pode ter expirado."
     }
   },
   "verifyEmail": {
@@ -68,19 +73,19 @@
     "checkSpam": "Se você não encontrar o e-mail, verifique sua pasta de spam."
   },
   "verify2fa": {
-    "title": "Autentica\u00e7\u00e3o de Dois Fatores",
-    "descriptionTotp": "Digite o c\u00f3digo de 6 d\u00edgitos do seu aplicativo autenticador",
-    "descriptionBackup": "Digite um dos seus c\u00f3digos de backup",
-    "authCode": "C\u00f3digo de Autentica\u00e7\u00e3o",
-    "backupCode": "C\u00f3digo de Backup",
-    "backupCodePlaceholder": "Digite o c\u00f3digo de backup",
+    "title": "Autenticação de Dois Fatores",
+    "descriptionTotp": "Digite o código de 6 dígitos do seu aplicativo autenticador",
+    "descriptionBackup": "Digite um dos seus códigos de backup",
+    "authCode": "Código de Autenticação",
+    "backupCode": "Código de Backup",
+    "backupCodePlaceholder": "Digite o código de backup",
     "totpPlaceholder": "000000",
     "trustDevice": "Confiar neste dispositivo por 30 dias",
     "verify": "Verificar",
     "useAuthenticator": "Usar aplicativo autenticador",
-    "useBackupCode": "Usar um c\u00f3digo de backup",
+    "useBackupCode": "Usar um código de backup",
     "errors": {
-      "invalidCode": "C\u00f3digo inv\u00e1lido"
+      "invalidCode": "Código inválido"
     }
   }
 }

+ 8 - 3
messages/ru/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Слишком много попыток, попробуйте позже.",
       "invalidCredentials": "Неверный адрес эл. почты или пароль",
       "passkeyFailed": "Вход по ключу доступа не удался. Попробуйте снова или используйте эл. почту и пароль."
-    }
+    },
+    "resetPasswordCta": "Сбросьте его здесь"
   },
   "signUp": {
     "title": "Создать аккаунт",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Не удалось создать аккаунт",
       "registrationDisabled": "Регистрация в настоящее время отключена. Обратитесь к администратору.",
-      "invitationFailed": "Не удалось принять приглашение"
-    }
+      "invitationFailed": "Не удалось принять приглашение",
+      "termsRequired": "Пожалуйста, примите условия использования, чтобы продолжить",
+      "emailExists": "Учётная запись с этим адресом электронной почты уже существует."
+    },
+    "passwordHint": "Не менее 8 символов",
+    "signInInstead": "Войдите вместо этого"
   },
   "forgotPassword": {
     "title": "Сброс пароля",

+ 8 - 3
messages/tr/auth.json

@@ -14,7 +14,8 @@
       "tooManyAttempts": "Çok fazla deneme yapıldı, lütfen daha sonra tekrar deneyin.",
       "invalidCredentials": "Geçersiz e-posta veya şifre",
       "passkeyFailed": "Passkey ile giriş başarısız. Tekrar deneyin veya e-posta ve şifre kullanın."
-    }
+    },
+    "resetPasswordCta": "Buradan sıfırlayın"
   },
   "signUp": {
     "title": "Hesap oluşturun",
@@ -30,8 +31,12 @@
     "errors": {
       "couldNotCreate": "Hesap oluşturulamadı",
       "registrationDisabled": "Kayıt şu anda devre dışı. Lütfen yöneticinizle iletişime geçin.",
-      "invitationFailed": "Davet kabul edilemedi"
-    }
+      "invitationFailed": "Davet kabul edilemedi",
+      "termsRequired": "Devam etmek için hizmet şartlarını kabul edin",
+      "emailExists": "Bu e-posta ile zaten bir hesap mevcut."
+    },
+    "passwordHint": "En az 8 karakter olmalı",
+    "signInInstead": "Bunun yerine giriş yapın"
   },
   "forgotPassword": {
     "title": "Şifrenizi sıfırlayın",

+ 25 - 10
src/app/(authenticated)/admin/users/page.tsx

@@ -1,19 +1,34 @@
-import { getUsers } from "@/features/admin/Actions/getUsers";
-import { AdminUsers } from "@/features/admin/Components/admin-users";
+import { getUsers } from '@/features/admin/Actions/getUsers'
+import { AdminUsers } from '@/features/admin/Components/admin-users'
 
 export default async function AdminUsersPage({
   searchParams,
 }: {
-  searchParams: Promise<{ search?: string; page?: string; pageSize?: string }>;
+  searchParams: Promise<{
+    search?: string
+    page?: string
+    pageSize?: string
+    sortBy?: string
+    sortOrder?: string
+  }>
 }) {
-  const params = await searchParams;
-  const page = Number(params.page) || 1;
-  const pageSize = Number(params.pageSize) || 20;
-  const search = params.search || "";
+  const params = await searchParams
+  const page = Number(params.page) || 1
+  const pageSize = Number(params.pageSize) || 20
+  const search = params.search || ''
+  const sortBy = params.sortBy
+  const sortOrder = params.sortOrder
 
-  const result = await getUsers({ search, page, pageSize });
+  const result = await getUsers({ search, page, pageSize, sortBy, sortOrder })
 
-  const data = result.data ?? { users: [], total: 0, page: 1, pageSize: 20, totalPages: 0 };
+  const data = result.data ?? { users: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }
 
-  return <AdminUsers data={data} search={search} />;
+  return (
+    <AdminUsers
+      data={data}
+      search={search}
+      sortBy={sortBy ?? 'lastSeen'}
+      sortOrder={sortOrder === 'asc' ? 'asc' : 'desc'}
+    />
+  )
 }

+ 25 - 6
src/app/(public)/auth/sign-in/sign-in-form.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { Suspense, useState } from 'react'
+import { Suspense, useRef, useState } from 'react'
 import { useRouter, useSearchParams } from 'next/navigation'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
@@ -20,6 +20,7 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
   const [error, setError] = useState('')
   const [loading, setLoading] = useState(false)
   const [passkeyLoading, setPasskeyLoading] = useState(false)
+  const passwordRef = useRef<HTMLInputElement>(null)
   const router = useRouter()
   const searchParams = useSearchParams()
 
@@ -36,6 +37,9 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
         } else {
           setError(result.error.message || t('errors.invalidCredentials'))
         }
+        // Clear password and refocus so the retry is one keystroke away
+        setPassword('')
+        passwordRef.current?.focus()
       } else {
         const redirect = searchParams.get('redirect') || '/'
         router.push(redirect)
@@ -81,9 +85,17 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
       </div>
 
       {error && (
-        <div className="mb-4 flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
-          <XCircle className="h-4 w-4 shrink-0" />
-          <span>{error}</span>
+        <div className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
+          <XCircle className="mt-0.5 h-4 w-4 shrink-0" />
+          <div className="flex-1">
+            <p>{error}</p>
+            <p className="mt-1 text-xs">
+              {t('forgotPassword')}{' '}
+              <Link href="/auth/forgot-password" className="font-medium underline">
+                {t('resetPasswordCta')}
+              </Link>
+            </p>
+          </div>
         </div>
       )}
 
@@ -105,6 +117,7 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
         <div className="space-y-2">
           <Label htmlFor="password">{tc('form.password')}</Label>
           <Input
+            ref={passwordRef}
             id="password"
             type="password"
             placeholder={t('passwordPlaceholder')}
@@ -114,7 +127,10 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
             className="h-11 bg-background/50"
           />
           <div className="flex justify-end">
-            <Link href="/auth/forgot-password" className="text-xs text-muted-foreground hover:underline">
+            <Link
+              href="/auth/forgot-password"
+              className="text-xs text-muted-foreground hover:underline"
+            >
               {t('forgotPassword')}
             </Link>
           </div>
@@ -153,7 +169,10 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
       {!registrationDisabled && (
         <p className="mt-6 text-center text-sm text-muted-foreground">
           {t('noAccount')}{' '}
-          <Link href={`/auth/sign-up${searchParams.get('redirect') ? `?redirect=${encodeURIComponent(searchParams.get('redirect')!)}` : ''}`} className="font-medium text-primary hover:underline">
+          <Link
+            href={`/auth/sign-up${searchParams.get('redirect') ? `?redirect=${encodeURIComponent(searchParams.get('redirect')!)}` : ''}`}
+            className="font-medium text-primary hover:underline"
+          >
             {t('createOne')}
           </Link>
         </p>

+ 75 - 23
src/app/(public)/auth/sign-up/sign-up-form.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useState } from 'react'
+import { useRef, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
@@ -12,7 +12,15 @@ import { Loader2, XCircle } from 'lucide-react'
 import { AuthLogo } from '@/components/auth-logo'
 import { acceptInvitation } from '@/features/team/Actions/acceptInvitation'
 
-export function SignUpForm({ inviteToken, emailVerificationRequired, redirectTo }: { inviteToken?: string; emailVerificationRequired?: boolean; redirectTo?: string }) {
+export function SignUpForm({
+  inviteToken,
+  emailVerificationRequired,
+  redirectTo,
+}: {
+  inviteToken?: string
+  emailVerificationRequired?: boolean
+  redirectTo?: string
+}) {
   const t = useTranslations('auth.signUp')
   const tc = useTranslations('common')
   const [name, setName] = useState('')
@@ -20,26 +28,47 @@ export function SignUpForm({ inviteToken, emailVerificationRequired, redirectTo
   const [password, setPassword] = useState('')
   const [termsAccepted, setTermsAccepted] = useState(false)
   const [error, setError] = useState('')
+  const [emailAlreadyExists, setEmailAlreadyExists] = useState(false)
+  const [showTermsError, setShowTermsError] = useState(false)
   const [loading, setLoading] = useState(false)
+  const termsRef = useRef<HTMLInputElement>(null)
   const router = useRouter()
 
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault()
+
+    // Validate terms before hitting the API — gives clear feedback instead of a silent disabled button
+    if (!termsAccepted) {
+      setError(t('errors.termsRequired'))
+      setShowTermsError(true)
+      termsRef.current?.focus()
+      termsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
+      return
+    }
+
     setLoading(true)
     setError('')
+    setEmailAlreadyExists(false)
+    setShowTermsError(false)
 
     try {
       const result = await signUp.email({ name, email, password })
       if (result.error) {
         const message = result.error.message || t('errors.couldNotCreate')
-        const isDisabled =
-          message.toLowerCase().includes('disabled') ||
-          message.toLowerCase().includes('failed to create')
-        setError(
-          isDisabled
-            ? t('errors.registrationDisabled')
-            : message,
-        )
+        const lower = message.toLowerCase()
+        const isDisabled = lower.includes('disabled') || lower.includes('failed to create')
+        const isDuplicate =
+          ('code' in result.error &&
+            typeof result.error.code === 'string' &&
+            result.error.code.toLowerCase().includes('exist')) ||
+          lower.includes('already') ||
+          lower.includes('exist')
+        if (isDuplicate) {
+          setEmailAlreadyExists(true)
+          setError(t('errors.emailExists'))
+        } else {
+          setError(isDisabled ? t('errors.registrationDisabled') : message)
+        }
       } else if (inviteToken) {
         const acceptResult = await acceptInvitation({ token: inviteToken })
         if (acceptResult.success) {
@@ -48,14 +77,18 @@ export function SignUpForm({ inviteToken, emailVerificationRequired, redirectTo
           router.refresh()
         } else {
           setError(acceptResult.error || t('errors.invitationFailed'))
-          router.push(redirectTo ? `/onboarding?redirect=${encodeURIComponent(redirectTo)}` : '/onboarding')
+          router.push(
+            redirectTo ? `/onboarding?redirect=${encodeURIComponent(redirectTo)}` : '/onboarding'
+          )
           router.refresh()
         }
       } else if (emailVerificationRequired) {
         router.push('/auth/verify-email')
         router.refresh()
       } else {
-        router.push(redirectTo ? `/onboarding?redirect=${encodeURIComponent(redirectTo)}` : '/onboarding')
+        router.push(
+          redirectTo ? `/onboarding?redirect=${encodeURIComponent(redirectTo)}` : '/onboarding'
+        )
         router.refresh()
       }
     } catch {
@@ -79,16 +112,23 @@ export function SignUpForm({ inviteToken, emailVerificationRequired, redirectTo
           </div>
           <h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
           <p className="mt-1 text-sm text-muted-foreground">
-            {inviteToken
-              ? t('descriptionInvite')
-              : t('descriptionDefault')}
+            {inviteToken ? t('descriptionInvite') : t('descriptionDefault')}
           </p>
         </div>
 
         {error && (
-          <div className="mb-4 flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
-            <XCircle className="h-4 w-4 shrink-0" />
-            <span>{error}</span>
+          <div className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
+            <XCircle className="mt-0.5 h-4 w-4 shrink-0" />
+            <div className="flex-1">
+              <p>{error}</p>
+              {emailAlreadyExists && (
+                <p className="mt-1 text-xs">
+                  <Link href="/auth/sign-in" className="font-medium underline">
+                    {t('signInInstead')}
+                  </Link>
+                </p>
+              )}
+            </div>
           </div>
         )}
 
@@ -131,26 +171,38 @@ export function SignUpForm({ inviteToken, emailVerificationRequired, redirectTo
               minLength={8}
               className="h-11 bg-background/50"
             />
+            <p className="text-xs text-muted-foreground">{t('passwordHint')}</p>
           </div>
 
-          <div className="flex items-center gap-2">
+          <div
+            className={`flex items-center gap-2 rounded-md p-2 transition-colors ${
+              showTermsError ? 'bg-destructive/10 ring-1 ring-destructive/40' : ''
+            }`}
+          >
             <input
+              ref={termsRef}
               id="terms"
               type="checkbox"
               checked={termsAccepted}
-              onChange={(e) => setTermsAccepted(e.target.checked)}
-              required
+              onChange={(e) => {
+                setTermsAccepted(e.target.checked)
+                if (e.target.checked) setShowTermsError(false)
+              }}
               className="h-4 w-4 shrink-0 rounded border-border accent-primary"
             />
             <Label htmlFor="terms" className="text-sm font-normal text-muted-foreground">
               {t('agreeToTerms')}{' '}
-              <Link href="/terms" target="_blank" className="font-medium text-primary hover:underline">
+              <Link
+                href="/terms"
+                target="_blank"
+                className="font-medium text-primary hover:underline"
+              >
                 {tc('terms.termsOfService')}
               </Link>
             </Label>
           </div>
 
-          <Button type="submit" className="h-11 w-full" disabled={loading || !termsAccepted}>
+          <Button type="submit" className="h-11 w-full" disabled={loading}>
             {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
             {inviteToken ? t('createAccountJoin') : t('createAccount')}
           </Button>

+ 1 - 1
src/components/posthog-provider.tsx

@@ -22,7 +22,7 @@ export function PostHogProvider({
       posthog.init(posthogKey, {
         api_host: posthogHost || 'https://us.i.posthog.com',
         person_profiles: 'identified_only',
-        capture_pageview: true,
+        capture_pageview: 'history_change',
         capture_pageleave: true,
       })
     }

+ 30 - 17
src/features/admin/Actions/getUsers.ts

@@ -1,21 +1,34 @@
-"use server";
+'use server'
 
-import { withSuperAdmin } from "@/lib/with-super-admin";
-import { db } from "@/lib/db";
-import { adminSearchSchema } from "../Schema/adminSchema";
+import { withSuperAdmin } from '@/lib/with-super-admin'
+import { db } from '@/lib/db'
+import { adminSearchSchema } from '../Schema/adminSchema'
 
-export async function getUsers(input?: { search?: string; page?: number; pageSize?: number }) {
+export async function getUsers(input?: {
+  search?: string
+  page?: number
+  pageSize?: number
+  sortBy?: string
+  sortOrder?: string
+}) {
   return withSuperAdmin(async () => {
-    const { search, page, pageSize } = adminSearchSchema.parse(input ?? {});
+    const { search, page, pageSize, sortBy, sortOrder } = adminSearchSchema.parse(input ?? {})
 
     const where = search
       ? {
           OR: [
-            { name: { contains: search, mode: "insensitive" as const } },
-            { email: { contains: search, mode: "insensitive" as const } },
+            { name: { contains: search, mode: 'insensitive' as const } },
+            { email: { contains: search, mode: 'insensitive' as const } },
           ],
         }
-      : {};
+      : {}
+
+    // For sortable nullable date columns, place nulls last in desc order so users
+    // who have actually signed in are surfaced before never-signed-in accounts.
+    const orderBy =
+      sortBy === 'lastSeen'
+        ? { lastSeen: { sort: sortOrder, nulls: 'last' as const } }
+        : { [sortBy]: sortOrder }
 
     const [users, total] = await Promise.all([
       db.user.findMany({
@@ -29,21 +42,21 @@ export async function getUsers(input?: { search?: string; page?: number; pageSiz
           lastSeen: true,
           emailVerified: true,
         },
-        orderBy: { createdAt: "desc" },
+        orderBy,
         skip: (page - 1) * pageSize,
         take: pageSize,
       }),
       db.user.count({ where }),
-    ]);
+    ])
 
-    const userIds = users.map((u) => u.id);
+    const userIds = users.map((u) => u.id)
     const membershipCounts = await db.organizationMember.groupBy({
-      by: ["userId"],
+      by: ['userId'],
       where: { userId: { in: userIds } },
       _count: true,
-    });
+    })
 
-    const countMap = new Map(membershipCounts.map((m) => [m.userId, m._count]));
+    const countMap = new Map(membershipCounts.map((m) => [m.userId, m._count]))
 
     return {
       users: users.map((u) => ({
@@ -60,6 +73,6 @@ export async function getUsers(input?: { search?: string; page?: number; pageSiz
       page,
       pageSize,
       totalPages: Math.ceil(total / pageSize),
-    };
-  });
+    }
+  })
 }

+ 194 - 105
src/features/admin/Components/admin-users.tsx

@@ -1,13 +1,13 @@
-"use client";
+'use client'
 
-import { useState, useCallback, useTransition } from "react";
-import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { useTranslations } from "next-intl";
-import { toast } from "sonner";
-import { useConfirm } from "@/components/confirm-dialog";
-import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
+import { useState, useCallback, useTransition } from 'react'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { useConfirm } from '@/components/confirm-dialog'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
 import {
   Table,
   TableBody,
@@ -15,140 +15,175 @@ import {
   TableHead,
   TableHeader,
   TableRow,
-} from "@/components/ui/table";
+} from '@/components/ui/table'
 import {
   DropdownMenu,
   DropdownMenuContent,
   DropdownMenuItem,
   DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { DataTablePagination } from "@/components/data-table-pagination";
-import { BadgeCheck, Loader2, MoreHorizontal, Search, ShieldCheck, ShieldOff, Trash2 } from "lucide-react";
-import { useFormatDate } from "@/lib/use-format-date";
-import { toggleSuperAdmin } from "../Actions/toggleSuperAdmin";
-import { deleteUser } from "../Actions/deleteUser";
+} from '@/components/ui/dropdown-menu'
+import { DataTablePagination } from '@/components/data-table-pagination'
+import {
+  ArrowDown,
+  ArrowUp,
+  ArrowUpDown,
+  BadgeCheck,
+  Loader2,
+  MoreHorizontal,
+  Search,
+  ShieldCheck,
+  ShieldOff,
+  Trash2,
+} from 'lucide-react'
+import { useFormatDate } from '@/lib/use-format-date'
+import { toggleSuperAdmin } from '../Actions/toggleSuperAdmin'
+import { deleteUser } from '../Actions/deleteUser'
 
 type UserRow = {
-  id: string;
-  name: string;
-  email: string;
-  isSuperAdmin: boolean;
-  emailVerified: boolean;
-  createdAt: string;
-  lastSeen: string | null;
-  organizationCount: number;
-};
+  id: string
+  name: string
+  email: string
+  isSuperAdmin: boolean
+  emailVerified: boolean
+  createdAt: string
+  lastSeen: string | null
+  organizationCount: number
+}
 
 function OnlineDot({ lastSeen }: { lastSeen: string | null }) {
-  if (!lastSeen) return null;
-  const isOnline = Date.now() - new Date(lastSeen).getTime() < 5 * 60 * 1000;
-  if (!isOnline) return null;
+  if (!lastSeen) return null
+  const isOnline = Date.now() - new Date(lastSeen).getTime() < 5 * 60 * 1000
+  if (!isOnline) return null
   return (
     <span className="relative flex h-2 w-2" title="Online">
       <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
       <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
     </span>
-  );
+  )
 }
 
 type PaginatedData = {
-  users: UserRow[];
-  total: number;
-  page: number;
-  pageSize: number;
-  totalPages: number;
-};
+  users: UserRow[]
+  total: number
+  page: number
+  pageSize: number
+  totalPages: number
+}
 
 export function AdminUsers({
   data,
   search,
+  sortBy,
+  sortOrder,
 }: {
-  data: PaginatedData;
-  search: string;
+  data: PaginatedData
+  search: string
+  sortBy: string
+  sortOrder: 'asc' | 'desc'
 }) {
-  const t = useTranslations("admin");
-  const router = useRouter();
-  const pathname = usePathname();
-  const searchParams = useSearchParams();
-  const confirm = useConfirm();
-  const { formatDate, formatDateTime } = useFormatDate();
-  const [isPending, startTransition] = useTransition();
-  const [searchInput, setSearchInput] = useState(search);
+  const t = useTranslations('admin')
+  const router = useRouter()
+  const pathname = usePathname()
+  const searchParams = useSearchParams()
+  const confirm = useConfirm()
+  const { formatDate, formatDateTime } = useFormatDate()
+  const [isPending, startTransition] = useTransition()
+  const [searchInput, setSearchInput] = useState(search)
 
   const navigate = useCallback(
     (params: Record<string, string | number | undefined>) => {
-      const newParams = new URLSearchParams(searchParams.toString());
+      const newParams = new URLSearchParams(searchParams.toString())
       for (const [key, value] of Object.entries(params)) {
-        if (value === undefined || value === "") {
-          newParams.delete(key);
+        if (value === undefined || value === '') {
+          newParams.delete(key)
         } else {
-          newParams.set(key, String(value));
+          newParams.set(key, String(value))
         }
       }
-      if (!("page" in params) && "search" in params) {
-        newParams.delete("page");
+      if (!('page' in params) && 'search' in params) {
+        newParams.delete('page')
       }
       startTransition(() => {
-        router.push(`${pathname}?${newParams.toString()}`);
-      });
+        router.push(`${pathname}?${newParams.toString()}`)
+      })
     },
-    [router, pathname, searchParams],
-  );
+    [router, pathname, searchParams]
+  )
 
   const handleSearch = useCallback(
     (e: React.FormEvent) => {
-      e.preventDefault();
-      navigate({ search: searchInput || undefined });
+      e.preventDefault()
+      navigate({ search: searchInput || undefined })
+    },
+    [navigate, searchInput]
+  )
+
+  const handleSort = useCallback(
+    (column: string) => {
+      const newOrder = sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc'
+      navigate({ sortBy: column, sortOrder: newOrder, page: undefined })
     },
-    [navigate, searchInput],
-  );
+    [navigate, sortBy, sortOrder]
+  )
+
+  const SortIcon = ({ column }: { column: string }) => {
+    if (sortBy !== column) return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
+    return sortOrder === 'asc' ? (
+      <ArrowUp className="ml-1 h-3 w-3" />
+    ) : (
+      <ArrowDown className="ml-1 h-3 w-3" />
+    )
+  }
 
   const handleToggleSuperAdmin = async (user: UserRow) => {
-    const action = user.isSuperAdmin ? "demote" : "promote";
+    const action = user.isSuperAdmin ? 'demote' : 'promote'
     const confirmed = await confirm({
-      title: action === "promote" ? t("users.promoteTitle") : t("users.demoteTitle"),
-      description: action === "promote" ? t("users.promoteConfirm", { name: user.name }) : t("users.demoteConfirm", { name: user.name }),
-      confirmLabel: action === "promote" ? t("users.promote") : t("users.demote"),
-      destructive: action === "demote",
-    });
+      title: action === 'promote' ? t('users.promoteTitle') : t('users.demoteTitle'),
+      description:
+        action === 'promote'
+          ? t('users.promoteConfirm', { name: user.name })
+          : t('users.demoteConfirm', { name: user.name }),
+      confirmLabel: action === 'promote' ? t('users.promote') : t('users.demote'),
+      destructive: action === 'demote',
+    })
 
-    if (!confirmed) return;
+    if (!confirmed) return
 
     startTransition(async () => {
       const result = await toggleSuperAdmin({
         userId: user.id,
         isSuperAdmin: !user.isSuperAdmin,
-      });
+      })
 
       if (result.success) {
-        toast.success(action === "promote" ? t("users.promotedSuccess") : t("users.demotedSuccess"));
-        router.refresh();
+        toast.success(action === 'promote' ? t('users.promotedSuccess') : t('users.demotedSuccess'))
+        router.refresh()
       } else {
-        toast.error(result.error ?? t("users.failedUpdate"));
+        toast.error(result.error ?? t('users.failedUpdate'))
       }
-    });
-  };
+    })
+  }
 
   const handleDeleteUser = async (user: UserRow) => {
     const confirmed = await confirm({
-      title: t("users.deleteTitle"),
-      description: t("users.deleteConfirm", { name: user.name, email: user.email }),
-      confirmLabel: t("users.delete"),
+      title: t('users.deleteTitle'),
+      description: t('users.deleteConfirm', { name: user.name, email: user.email }),
+      confirmLabel: t('users.delete'),
       destructive: true,
-    });
+    })
 
-    if (!confirmed) return;
+    if (!confirmed) return
 
     startTransition(async () => {
-      const result = await deleteUser({ userId: user.id });
+      const result = await deleteUser({ userId: user.id })
       if (result.success) {
-        toast.success(t("users.deletedSuccess"));
-        router.refresh();
+        toast.success(t('users.deletedSuccess'))
+        router.refresh()
       } else {
-        toast.error(result.error ?? t("users.failedDelete"));
+        toast.error(result.error ?? t('users.failedDelete'))
       }
-    });
-  };
+    })
+  }
 
   return (
     <div className="space-y-4">
@@ -156,7 +191,7 @@ export function AdminUsers({
         <form onSubmit={handleSearch} className="relative flex-1 sm:max-w-sm">
           <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
           <Input
-            placeholder={t("users.searchPlaceholder")}
+            placeholder={t('users.searchPlaceholder')}
             value={searchInput}
             onChange={(e) => setSearchInput(e.target.value)}
             className="pl-9"
@@ -169,12 +204,57 @@ export function AdminUsers({
         <Table>
           <TableHeader>
             <TableRow>
-              <TableHead>{t("users.name")}</TableHead>
-              <TableHead>{t("users.email")}</TableHead>
-              <TableHead>{t("users.role")}</TableHead>
-              <TableHead className="text-center">{t("users.orgs")}</TableHead>
-              <TableHead>{t("users.created")}</TableHead>
-              <TableHead>{t("users.lastSeen")}</TableHead>
+              <TableHead>
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('name')}
+                >
+                  {t('users.name')}
+                  <SortIcon column="name" />
+                </button>
+              </TableHead>
+              <TableHead>
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('email')}
+                >
+                  {t('users.email')}
+                  <SortIcon column="email" />
+                </button>
+              </TableHead>
+              <TableHead>
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('isSuperAdmin')}
+                >
+                  {t('users.role')}
+                  <SortIcon column="isSuperAdmin" />
+                </button>
+              </TableHead>
+              <TableHead className="text-center">{t('users.orgs')}</TableHead>
+              <TableHead>
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('createdAt')}
+                >
+                  {t('users.created')}
+                  <SortIcon column="createdAt" />
+                </button>
+              </TableHead>
+              <TableHead>
+                <button
+                  type="button"
+                  className="flex items-center hover:text-foreground"
+                  onClick={() => handleSort('lastSeen')}
+                >
+                  {t('users.lastSeen')}
+                  <SortIcon column="lastSeen" />
+                </button>
+              </TableHead>
               <TableHead className="w-10" />
             </TableRow>
           </TableHeader>
@@ -182,7 +262,7 @@ export function AdminUsers({
             {data.users.length === 0 ? (
               <TableRow>
                 <TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
-                  {search ? t("users.noResults") : t("users.noUsers")}
+                  {search ? t('users.noResults') : t('users.noUsers')}
                 </TableCell>
               </TableRow>
             ) : (
@@ -193,7 +273,9 @@ export function AdminUsers({
                     <span className="flex items-center gap-1.5">
                       {user.email}
                       {user.emailVerified && (
-                        <span title={t("users.emailVerified")}><BadgeCheck className="h-4 w-4 text-blue-500" /></span>
+                        <span title={t('users.emailVerified')}>
+                          <BadgeCheck className="h-4 w-4 text-blue-500" />
+                        </span>
                       )}
                     </span>
                   </TableCell>
@@ -201,32 +283,39 @@ export function AdminUsers({
                     {user.isSuperAdmin ? (
                       <Badge variant="default" className="gap-1">
                         <ShieldCheck className="h-3 w-3" />
-                        {t("users.superAdmin")}
+                        {t('users.superAdmin')}
                       </Badge>
                     ) : (
-                      <Badge variant="secondary">{t("users.user")}</Badge>
+                      <Badge variant="secondary">{t('users.user')}</Badge>
                     )}
                   </TableCell>
                   <TableCell className="text-center">{user.organizationCount}</TableCell>
+                  <TableCell>{formatDate(user.createdAt)}</TableCell>
                   <TableCell>
-                    {formatDate(user.createdAt)}
-                  </TableCell>
-                  <TableCell>
-                    {user.lastSeen && Date.now() - new Date(user.lastSeen).getTime() < 5 * 60 * 1000 ? (
+                    {user.lastSeen &&
+                    Date.now() - new Date(user.lastSeen).getTime() < 5 * 60 * 1000 ? (
                       <span className="flex items-center gap-1.5">
                         <OnlineDot lastSeen={user.lastSeen} />
-                        <span className="text-sm text-green-600 dark:text-green-400">{t("users.online")}</span>
+                        <span className="text-sm text-green-600 dark:text-green-400">
+                          {t('users.online')}
+                        </span>
                       </span>
                     ) : (
                       <span className="text-sm text-muted-foreground">
-                        {user.lastSeen ? formatDateTime(user.lastSeen) : "—"}
+                        {user.lastSeen ? formatDateTime(user.lastSeen) : '—'}
                       </span>
                     )}
                   </TableCell>
                   <TableCell>
                     <DropdownMenu>
                       <DropdownMenuTrigger asChild>
-                        <Button variant="ghost" size="icon" className="h-8 w-8" disabled={isPending} aria-label={t("common.openMenu")}>
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          className="h-8 w-8"
+                          disabled={isPending}
+                          aria-label={t('common.openMenu')}
+                        >
                           <MoreHorizontal className="h-4 w-4" />
                         </Button>
                       </DropdownMenuTrigger>
@@ -235,12 +324,12 @@ export function AdminUsers({
                           {user.isSuperAdmin ? (
                             <>
                               <ShieldOff className="mr-2 h-4 w-4" />
-                              {t("users.demoteFromSuperAdmin")}
+                              {t('users.demoteFromSuperAdmin')}
                             </>
                           ) : (
                             <>
                               <ShieldCheck className="mr-2 h-4 w-4" />
-                              {t("users.promoteToSuperAdmin")}
+                              {t('users.promoteToSuperAdmin')}
                             </>
                           )}
                         </DropdownMenuItem>
@@ -249,7 +338,7 @@ export function AdminUsers({
                           className="text-destructive focus:text-destructive"
                         >
                           <Trash2 className="mr-2 h-4 w-4" />
-                          {t("users.deleteUser")}
+                          {t('users.deleteUser')}
                         </DropdownMenuItem>
                       </DropdownMenuContent>
                     </DropdownMenu>
@@ -269,5 +358,5 @@ export function AdminUsers({
         onNavigate={navigate}
       />
     </div>
-  );
+  )
 }

+ 17 - 12
src/features/admin/Schema/adminSchema.ts

@@ -1,25 +1,30 @@
-import { z } from "zod";
+import { z } from 'zod'
 
 export const toggleSuperAdminSchema = z.object({
-  userId: z.string().min(1, "User ID is required"),
+  userId: z.string().min(1, 'User ID is required'),
   isSuperAdmin: z.boolean(),
-});
+})
 
 export const deleteUserSchema = z.object({
-  userId: z.string().min(1, "User ID is required"),
-});
+  userId: z.string().min(1, 'User ID is required'),
+})
 
 export const deleteOrganizationSchema = z.object({
-  organizationId: z.string().min(1, "Organization ID is required"),
-});
+  organizationId: z.string().min(1, 'Organization ID is required'),
+})
+
+export const userSortFields = ['name', 'email', 'createdAt', 'lastSeen', 'isSuperAdmin'] as const
+export type UserSortField = (typeof userSortFields)[number]
 
 export const adminSearchSchema = z.object({
   search: z.string().optional(),
   page: z.number().int().min(1).default(1),
   pageSize: z.number().int().min(1).max(100).default(20),
-});
+  sortBy: z.enum(userSortFields).default('lastSeen'),
+  sortOrder: z.enum(['asc', 'desc']).default('desc'),
+})
 
-export type ToggleSuperAdminInput = z.infer<typeof toggleSuperAdminSchema>;
-export type DeleteUserInput = z.infer<typeof deleteUserSchema>;
-export type DeleteOrganizationInput = z.infer<typeof deleteOrganizationSchema>;
-export type AdminSearchInput = z.infer<typeof adminSearchSchema>;
+export type ToggleSuperAdminInput = z.infer<typeof toggleSuperAdminSchema>
+export type DeleteUserInput = z.infer<typeof deleteUserSchema>
+export type DeleteOrganizationInput = z.infer<typeof deleteOrganizationSchema>
+export type AdminSearchInput = z.infer<typeof adminSearchSchema>