feat(auth): admin UI for user management + change-password frontend
CI / Build Native (push) Failing after 3m4s
CI / Build Native (push) Failing after 3m4s
Backend additions:
- User.lastLoginAt column (updated on each successful login)
- AdminUserSummary DTO (id, username, createdAt, lastLoginAt, isAdmin, mustChangePassword)
- GET /api/auth/admin/users (RolesAllowed("admin")) -> array of summaries
- AuthService.listUsersForAdmin() + AuthService.authenticate() now @Transactional and bumps lastLoginAt
Frontend (Phase 1):
- User type extended with mustChangePassword + isAdmin
- api.changePassword() / api.adminResetPassword() / api.adminListUsers()
- AuthContext exposes changePassword
- ChangePasswordPage.tsx (full-page, current + new + confirm, errors inline)
- App.tsx routes LoginPage -> ChangePasswordPage -> AuthenticatedApp
Frontend (Phase 2):
- TabBar supports optional 'usuarios' tab (shown only if user.isAdmin)
- UsersAdminPage.tsx: lista todos los usuarios con badges de rol y estado,
botón "Resetear contraseña" con modal inline que llama adminResetPassword
- Header de AuthenticatedApp muestra un badge 'admin' al lado del username
Both mvn compile and npm tsc + vite build pass clean.
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AuthProvider, useAuth } from './auth/AuthContext'
|
import { AuthProvider, useAuth } from './auth/AuthContext'
|
||||||
import { LoginPage } from './auth/LoginPage'
|
import { LoginPage } from './auth/LoginPage'
|
||||||
|
import { ChangePasswordPage } from './auth/ChangePasswordPage'
|
||||||
import { TabBar, type TabKey } from './components/TabBar'
|
import { TabBar, type TabKey } from './components/TabBar'
|
||||||
import { InsumosSection } from './components/InsumosSection'
|
import { InsumosSection } from './components/InsumosSection'
|
||||||
import { FormulasSection } from './components/FormulasSection'
|
import { FormulasSection } from './components/FormulasSection'
|
||||||
import { CalculadoraSection } from './components/CalculadoraSection'
|
import { CalculadoraSection } from './components/CalculadoraSection'
|
||||||
import { HistorySection } from './components/HistorySection'
|
import { HistorySection } from './components/HistorySection'
|
||||||
|
import { UsersAdminPage } from './components/UsersAdminPage'
|
||||||
import { SaveIndicator } from './components/SaveIndicator'
|
import { SaveIndicator } from './components/SaveIndicator'
|
||||||
import { usePersistedState } from './hooks/usePersistedState'
|
import { usePersistedState } from './hooks/usePersistedState'
|
||||||
import { makeDefaultAppState, makeEmptyAppState } from './data/defaults'
|
import { makeDefaultAppState, makeEmptyAppState } from './data/defaults'
|
||||||
@@ -22,6 +24,7 @@ function AppRouter() {
|
|||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
if (loading) return <LoadingScreen message="Verificando sesión…" />
|
if (loading) return <LoadingScreen message="Verificando sesión…" />
|
||||||
if (!user) return <LoginPage />
|
if (!user) return <LoginPage />
|
||||||
|
if (user.mustChangePassword) return <ChangePasswordPage />
|
||||||
return <AuthenticatedApp />
|
return <AuthenticatedApp />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +49,8 @@ function AuthenticatedApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdmin = user?.isAdmin ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<header className="bg-white border-b border-slate-200">
|
<header className="bg-white border-b border-slate-200">
|
||||||
@@ -56,6 +61,11 @@ function AuthenticatedApp() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
<span className="font-medium text-slate-700">{user?.username ?? ''}</span>
|
<span className="font-medium text-slate-700">{user?.username ?? ''}</span>
|
||||||
|
{isAdmin && (
|
||||||
|
<span className="ml-2 text-xs px-2 py-0.5 rounded bg-blue-50 text-blue-700 align-middle">
|
||||||
|
admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{' · '}
|
{' · '}
|
||||||
Lineage 2 — Interlude / Clásico
|
Lineage 2 — Interlude / Clásico
|
||||||
</p>
|
</p>
|
||||||
@@ -93,7 +103,7 @@ function AuthenticatedApp() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TabBar active={tab} onChange={setTab} />
|
<TabBar active={tab} onChange={setTab} showUsersTab={isAdmin} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 py-6">
|
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 py-6">
|
||||||
@@ -107,6 +117,7 @@ function AuthenticatedApp() {
|
|||||||
<CalculadoraSection state={state} onChange={setState} />
|
<CalculadoraSection state={state} onChange={setState} />
|
||||||
)}
|
)}
|
||||||
{tab === 'historial' && <HistorySection />}
|
{tab === 'historial' && <HistorySection />}
|
||||||
|
{tab === 'usuarios' && isAdmin && <UsersAdminPage />}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="bg-white border-t border-slate-200 py-3">
|
<footer className="bg-white border-t border-slate-200 py-3">
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ export interface User {
|
|||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
mustChangePassword: boolean
|
||||||
|
isAdmin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserSummary {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
createdAt: string
|
||||||
|
lastLoginAt: string | null
|
||||||
|
isAdmin: boolean
|
||||||
|
mustChangePassword: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
@@ -93,6 +104,24 @@ export const api = {
|
|||||||
return request<void>('/api/auth/logout', { method: 'POST' })
|
return request<void>('/api/auth/logout', { method: 'POST' })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async changePassword(currentPassword: string, newPassword: string): Promise<User> {
|
||||||
|
return request<User>('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async adminResetPassword(username: string, newPassword: string): Promise<void> {
|
||||||
|
return request<void>('/api/auth/admin/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, newPassword }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async adminListUsers(): Promise<AdminUserSummary[]> {
|
||||||
|
return request<AdminUserSummary[]>('/api/auth/admin/users')
|
||||||
|
},
|
||||||
|
|
||||||
async getState(): Promise<AppState | null> {
|
async getState(): Promise<AppState | null> {
|
||||||
try {
|
try {
|
||||||
return await request<AppState>('/api/state')
|
return await request<AppState>('/api/state')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface AuthContextValue {
|
|||||||
login: (username: string, password: string) => Promise<void>
|
login: (username: string, password: string) => Promise<void>
|
||||||
register: (username: string, password: string) => Promise<void>
|
register: (username: string, password: string) => Promise<void>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
|
changePassword: (currentPassword: string, newPassword: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
@@ -50,8 +51,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(null)
|
setUser(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
|
||||||
|
const u = await api.changePassword(currentPassword, newPassword)
|
||||||
|
setUser(u)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
<AuthContext.Provider value={{ user, loading, login, register, logout, changePassword }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
|
|
||||||
|
export function ChangePasswordPage() {
|
||||||
|
const { changePassword } = useAuth()
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('La confirmación no coincide con la nueva contraseña.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setError('La nueva contraseña debe tener al menos 8 caracteres.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await changePassword(currentPassword, newPassword)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
setError('La contraseña actual es incorrecta.')
|
||||||
|
} else {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Error desconocido'
|
||||||
|
setError(msg)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-100 flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 shadow-sm p-8">
|
||||||
|
<header className="mb-6 text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Cambiar contraseña</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
Por seguridad, cambiá la contraseña provisional antes de seguir.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Contraseña actual
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
className="input-editable w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Nueva contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="input-editable w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Confirmar nueva contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="input-editable w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full px-4 py-2 text-sm font-semibold rounded bg-blue-600 text-white hover:bg-blue-700 disabled:bg-slate-300 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{submitting ? 'Procesando…' : 'Guardar y continuar'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-xs text-slate-400">
|
||||||
|
La contraseña provisional la encontrás en el log de Docker
|
||||||
|
(<code>docker logs shot-crafter | grep BOOTSTRAP-ADMIN-PASSWORD</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
export type TabKey = 'insumos' | 'formulas' | 'calculadora' | 'historial'
|
export type TabKey = 'insumos' | 'formulas' | 'calculadora' | 'historial' | 'usuarios'
|
||||||
|
|
||||||
interface TabBarProps {
|
interface TabBarProps {
|
||||||
active: TabKey
|
active: TabKey
|
||||||
onChange: (key: TabKey) => void
|
onChange: (key: TabKey) => void
|
||||||
|
showUsersTab?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS: Array<{ key: TabKey; label: string; subtitle: string }> = [
|
const TABS: Array<{ key: TabKey; label: string; subtitle: string }> = [
|
||||||
@@ -10,13 +11,15 @@ const TABS: Array<{ key: TabKey; label: string; subtitle: string }> = [
|
|||||||
{ key: 'formulas', label: '2. Fórmulas', subtitle: 'Recetas' },
|
{ key: 'formulas', label: '2. Fórmulas', subtitle: 'Recetas' },
|
||||||
{ key: 'calculadora', label: '3. Calculadora', subtitle: 'Rentabilidad' },
|
{ key: 'calculadora', label: '3. Calculadora', subtitle: 'Rentabilidad' },
|
||||||
{ key: 'historial', label: '4. Historial', subtitle: 'Producción' },
|
{ key: 'historial', label: '4. Historial', subtitle: 'Producción' },
|
||||||
|
{ key: 'usuarios', label: '5. Usuarios', subtitle: 'Admin' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function TabBar({ active, onChange }: TabBarProps) {
|
export function TabBar({ active, onChange, showUsersTab = false }: TabBarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-slate-200 bg-white">
|
<div className="border-b border-slate-200 bg-white">
|
||||||
<nav className="flex gap-1 px-4" aria-label="Tabs">
|
<nav className="flex gap-1 px-4" aria-label="Tabs">
|
||||||
{TABS.map((tab) => {
|
{TABS.map((tab) => {
|
||||||
|
if (tab.key === 'usuarios' && !showUsersTab) return null
|
||||||
const isActive = tab.key === active
|
const isActive = tab.key === active
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { ApiError, api, type AdminUserSummary } from '../api/client'
|
||||||
|
|
||||||
|
export function UsersAdminPage() {
|
||||||
|
const [users, setUsers] = useState<AdminUserSummary[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [resetFor, setResetFor] = useState<AdminUserSummary | null>(null)
|
||||||
|
const [resetError, setResetError] = useState<string | null>(null)
|
||||||
|
const [resetPassword, setResetPassword] = useState('')
|
||||||
|
const [resetSubmitting, setResetSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const list = await api.adminListUsers()
|
||||||
|
setUsers(list)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) setError(`HTTP ${e.status}: ${e.message}`)
|
||||||
|
else setError(e instanceof Error ? e.message : 'Error desconocido')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const handleReset = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!resetFor) return
|
||||||
|
setResetSubmitting(true)
|
||||||
|
setResetError(null)
|
||||||
|
try {
|
||||||
|
await api.adminResetPassword(resetFor.username, resetPassword)
|
||||||
|
setResetFor(null)
|
||||||
|
setResetPassword('')
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) setResetError(`HTTP ${e.status}: ${e.message}`)
|
||||||
|
else setResetError(e instanceof Error ? e.message : 'Error desconocido')
|
||||||
|
} finally {
|
||||||
|
setResetSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">Usuarios</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Reseteá la contraseña de cualquier usuario. Al resetear, deberá
|
||||||
|
cambiarla en su próximo login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={load}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-3 py-2 text-sm font-medium rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Cargando…' : 'Refrescar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded px-3 py-2 mb-4">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Username</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Rol</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Estado</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Creado</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Último login</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className="border-t border-slate-200">
|
||||||
|
<td className="px-4 py-2 font-medium text-slate-900">{u.username}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{u.isAdmin ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-blue-50 text-blue-700">
|
||||||
|
admin
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">
|
||||||
|
usuario
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{u.mustChangePassword ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-amber-50 text-amber-700">
|
||||||
|
cambiar pass
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-emerald-50 text-emerald-700">
|
||||||
|
ok
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-slate-600">
|
||||||
|
{new Date(u.createdAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-slate-600">
|
||||||
|
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setResetFor(u)
|
||||||
|
setResetPassword('')
|
||||||
|
setResetError(null)
|
||||||
|
}}
|
||||||
|
className="text-xs px-3 py-1 rounded border border-rose-300 bg-white text-rose-700 hover:bg-rose-50"
|
||||||
|
>
|
||||||
|
Resetear contraseña
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{users.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-6 text-center text-slate-500">
|
||||||
|
Sin usuarios para listar.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resetFor && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center px-4 z-10">
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 shadow-lg w-full max-w-md p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-1">
|
||||||
|
Resetear contraseña de {resetFor.username}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-500 mb-4">
|
||||||
|
El usuario deberá cambiar esta contraseña en su próximo login.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleReset}>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Nueva contraseña temporal
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={resetPassword}
|
||||||
|
onChange={(e) => setResetPassword(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="input-editable w-full mb-3"
|
||||||
|
/>
|
||||||
|
{resetError && (
|
||||||
|
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded px-3 py-2 mb-3">
|
||||||
|
{resetError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setResetFor(null)}
|
||||||
|
disabled={resetSubmitting}
|
||||||
|
className="px-3 py-2 text-sm font-medium rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={resetSubmitting || resetPassword.length < 8}
|
||||||
|
className="px-3 py-2 text-sm font-semibold rounded bg-rose-600 text-white hover:bg-rose-700 disabled:bg-slate-300 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{resetSubmitting ? 'Reseteando…' : 'Resetear'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.l2.shots.auth;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class AdminUserSummary {
|
||||||
|
public UUID id;
|
||||||
|
public String username;
|
||||||
|
public Instant createdAt;
|
||||||
|
public Instant lastLoginAt;
|
||||||
|
public boolean isAdmin;
|
||||||
|
public boolean mustChangePassword;
|
||||||
|
|
||||||
|
public AdminUserSummary() {}
|
||||||
|
|
||||||
|
public AdminUserSummary(User u) {
|
||||||
|
this.id = u.id;
|
||||||
|
this.username = u.username;
|
||||||
|
this.createdAt = u.createdAt;
|
||||||
|
this.lastLoginAt = u.lastLoginAt;
|
||||||
|
this.isAdmin = u.isAdmin;
|
||||||
|
this.mustChangePassword = u.mustChangePassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,13 @@ public class AuthResource {
|
|||||||
.orElse(Response.status(401).build());
|
.orElse(Response.status(401).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/admin/users")
|
||||||
|
@RolesAllowed("admin")
|
||||||
|
public Response adminListUsers() {
|
||||||
|
return Response.ok(authService.listUsersForAdmin()).build();
|
||||||
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/check")
|
@Path("/check")
|
||||||
@Authenticated
|
@Authenticated
|
||||||
|
|||||||
@@ -64,14 +64,23 @@ public class AuthService {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public Optional<User> authenticate(String username, String password) {
|
public Optional<User> authenticate(String username, String password) {
|
||||||
if (username == null || password == null) return Optional.empty();
|
if (username == null || password == null) return Optional.empty();
|
||||||
User user = User.findByUsernameCaseInsensitive(username.trim());
|
User user = User.findByUsernameCaseInsensitive(username.trim());
|
||||||
if (user == null) return Optional.empty();
|
if (user == null) return Optional.empty();
|
||||||
if (!BcryptUtil.matches(password, user.passwordHash)) return Optional.empty();
|
if (!BcryptUtil.matches(password, user.passwordHash)) return Optional.empty();
|
||||||
|
user.lastLoginAt = Instant.now();
|
||||||
|
user.persist();
|
||||||
return Optional.of(user);
|
return Optional.of(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public java.util.List<AdminUserSummary> listUsersForAdmin() {
|
||||||
|
return User.<User>listAll().stream()
|
||||||
|
.map(AdminUserSummary::new)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
public String buildToken(UUID userId) {
|
public String buildToken(UUID userId) {
|
||||||
User user = User.findById(userId);
|
User user = User.findById(userId);
|
||||||
if (user == null) throw new IllegalStateException("user not found: " + userId);
|
if (user == null) throw new IllegalStateException("user not found: " + userId);
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ public class User extends PanacheEntityBase {
|
|||||||
@Column(name = "is_admin", nullable = false)
|
@Column(name = "is_admin", nullable = false)
|
||||||
public boolean isAdmin = false;
|
public boolean isAdmin = false;
|
||||||
|
|
||||||
|
@Column(name = "last_login_at")
|
||||||
|
public Instant lastLoginAt;
|
||||||
|
|
||||||
public static User findByUsername(String username) {
|
public static User findByUsername(String username) {
|
||||||
return find("username", username.toLowerCase()).firstResult();
|
return find("username", username.toLowerCase()).firstResult();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user