Skip to content
GitLab
Projects
Groups
Topics
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Register
Sign in
Toggle navigation
Menu
Paulo Avila
petruz-tasks
Commits
f85352f0
Commit
f85352f0
authored
Jun 10, 2026
by
Paulo Avila
Browse files
tela de admin para reset de senha
parent
eae4ed0f
Pipeline
#21
failed with stages
in 0 seconds
Changes
8
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
README.md
View file @
f85352f0
...
@@ -67,6 +67,19 @@ Para produção: `npm run build && npm start`.
...
@@ -67,6 +67,19 @@ Para produção: `npm run build && npm start`.
espaço com barras de progresso.
espaço com barras de progresso.
-
**Tema**
: claro/escuro (toggle na barra superior), paleta açaí.
-
**Tema**
: claro/escuro (toggle na barra superior), paleta açaí.
## Super admin e reset de senha
Promova um usuário a
**super admin**
direto no banco:
```
sql
UPDATE
dbo
.
Users
SET
Role
=
'superadmin'
WHERE
Email
=
'email@exemplo.com'
;
```
O usuário promovido passa a ver o item
**Administração**
no menu (vale na
hora, sem novo login — o papel é lido do banco a cada acesso). Na página
`/admin`
ele vê todos os usuários e pode
**resetar a senha**
de qualquer um
(com gerador de senha aleatória); a nova senha é hasheada com bcrypt.
## Estrutura do banco (criada automaticamente)
## Estrutura do banco (criada automaticamente)
| Tabela | Descrição |
| Tabela | Descrição |
...
...
src/app/(app)/admin/page.tsx
0 → 100644
View file @
f85352f0
import
{
ShieldCheck
}
from
"
lucide-react
"
;
import
{
requireSuperAdmin
,
SUPER_ADMIN_ROLE
}
from
"
@/lib/authz
"
;
import
{
listAllUsers
}
from
"
@/lib/data
"
;
import
{
formatDate
}
from
"
@/lib/dates
"
;
import
{
Badge
}
from
"
@/components/ui/badge
"
;
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeader
,
TableRow
,
}
from
"
@/components/ui/table
"
;
import
{
ResetPasswordDialog
}
from
"
@/components/admin/reset-password-dialog
"
;
export
const
metadata
=
{
title
:
"
Administração — Petruz Tasks
"
};
export
default
async
function
AdminPage
()
{
const
admin
=
await
requireSuperAdmin
();
const
users
=
await
listAllUsers
();
return
(
<
div
className
=
"space-y-6"
>
<
div
>
<
h1
className
=
"flex items-center gap-2 text-2xl font-bold"
>
<
ShieldCheck
className
=
"h-6 w-6 text-primary"
/>
Administração de usuários
</
h1
>
<
p
className
=
"text-sm text-muted-foreground"
>
Área restrita a super admins. Resete a senha de qualquer usuário do
sistema.
</
p
>
</
div
>
<
div
className
=
"rounded-lg border"
>
<
Table
>
<
TableHeader
>
<
TableRow
>
<
TableHead
>
Nome
</
TableHead
>
<
TableHead
>
E-mail
</
TableHead
>
<
TableHead
>
Papel
</
TableHead
>
<
TableHead
>
Cadastro
</
TableHead
>
<
TableHead
className
=
"w-[160px]"
/>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
users
.
map
((
user
)
=>
(
<
TableRow
key
=
{
user
.
Id
}
>
<
TableCell
className
=
"font-medium"
>
{
user
.
Name
}
{
user
.
Id
===
admin
.
id
&&
(
<
span
className
=
"ml-2 text-xs text-muted-foreground"
>
(você)
</
span
>
)
}
</
TableCell
>
<
TableCell
>
{
user
.
Email
}
</
TableCell
>
<
TableCell
>
<
Badge
variant
=
{
user
.
Role
===
SUPER_ADMIN_ROLE
?
"
default
"
:
"
secondary
"
}
>
{
user
.
Role
===
SUPER_ADMIN_ROLE
?
"
Super admin
"
:
"
Usuário
"
}
</
Badge
>
</
TableCell
>
<
TableCell
className
=
"text-sm text-muted-foreground"
>
{
formatDate
(
user
.
CreatedAt
)
}
</
TableCell
>
<
TableCell
className
=
"text-right"
>
<
ResetPasswordDialog
userId
=
{
user
.
Id
}
userName
=
{
user
.
Name
}
/>
</
TableCell
>
</
TableRow
>
))
}
</
TableBody
>
</
Table
>
</
div
>
<
p
className
=
"text-xs text-muted-foreground"
>
Para promover alguém a super admin, execute no banco de dados:
{
"
"
}
<
code
className
=
"rounded bg-muted px-1.5 py-0.5"
>
UPDATE dbo.Users SET Role =
'
superadmin
'
WHERE Email =
'
email@exemplo.com
'
</
code
>
</
p
>
</
div
>
);
}
src/app/(app)/layout.tsx
View file @
f85352f0
import
{
requireUser
}
from
"
@/lib/authz
"
;
import
{
isSuperAdmin
,
requireUser
}
from
"
@/lib/authz
"
;
import
{
listWorkspacesForUser
}
from
"
@/lib/data
"
;
import
{
listWorkspacesForUser
}
from
"
@/lib/data
"
;
import
{
Sidebar
}
from
"
@/components/layout/sidebar
"
;
import
{
Sidebar
}
from
"
@/components/layout/sidebar
"
;
import
{
Topbar
}
from
"
@/components/layout/topbar
"
;
import
{
Topbar
}
from
"
@/components/layout/topbar
"
;
...
@@ -8,15 +8,19 @@ export default async function AppLayout({
...
@@ -8,15 +8,19 @@ export default async function AppLayout({
}:
Readonly
<
{
children
:
React
.
ReactNode
}
>
)
{
}:
Readonly
<
{
children
:
React
.
ReactNode
}
>
)
{
const
user
=
await
requireUser
();
const
user
=
await
requireUser
();
let
workspaces
:
Awaited
<
ReturnType
<
typeof
listWorkspacesForUser
>>
=
[];
let
workspaces
:
Awaited
<
ReturnType
<
typeof
listWorkspacesForUser
>>
=
[];
let
superAdmin
=
false
;
try
{
try
{
workspaces
=
await
listWorkspacesForUser
(
user
.
id
);
[
workspaces
,
superAdmin
]
=
await
Promise
.
all
([
listWorkspacesForUser
(
user
.
id
),
isSuperAdmin
(
user
.
id
),
]);
}
catch
(
err
)
{
}
catch
(
err
)
{
console
.
error
(
"
Falha ao carregar workspaces:
"
,
err
);
console
.
error
(
"
Falha ao carregar workspaces:
"
,
err
);
}
}
return
(
return
(
<
div
className
=
"flex min-h-screen"
>
<
div
className
=
"flex min-h-screen"
>
<
Sidebar
workspaces
=
{
workspaces
}
/>
<
Sidebar
workspaces
=
{
workspaces
}
isSuperAdmin
=
{
superAdmin
}
/>
<
div
className
=
"flex min-w-0 flex-1 flex-col"
>
<
div
className
=
"flex min-w-0 flex-1 flex-col"
>
<
Topbar
user
=
{
user
}
/>
<
Topbar
user
=
{
user
}
/>
<
main
className
=
"flex-1 p-4 md:p-6"
>
{
children
}
</
main
>
<
main
className
=
"flex-1 p-4 md:p-6"
>
{
children
}
</
main
>
...
...
src/components/admin/reset-password-dialog.tsx
0 → 100644
View file @
f85352f0
"
use client
"
;
import
{
useEffect
,
useState
}
from
"
react
"
;
import
{
KeyRound
,
RefreshCw
}
from
"
lucide-react
"
;
import
{
useFormState
,
useFormStatus
}
from
"
react-dom
"
;
import
{
toast
}
from
"
sonner
"
;
import
{
resetPasswordAction
,
type
ActionState
}
from
"
@/lib/actions
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Dialog
,
DialogContent
,
DialogDescription
,
DialogHeader
,
DialogTitle
,
DialogTrigger
,
}
from
"
@/components/ui/dialog
"
;
import
{
Input
}
from
"
@/components/ui/input
"
;
import
{
Label
}
from
"
@/components/ui/label
"
;
import
{
Alert
,
AlertDescription
}
from
"
@/components/ui/alert
"
;
function
generatePassword
():
string
{
const
chars
=
"
ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%
"
;
const
bytes
=
new
Uint8Array
(
12
);
crypto
.
getRandomValues
(
bytes
);
return
Array
.
from
(
bytes
,
(
b
)
=>
chars
[
b
%
chars
.
length
]).
join
(
""
);
}
function
SubmitButton
()
{
const
{
pending
}
=
useFormStatus
();
return
(
<
Button
type
=
"submit"
disabled
=
{
pending
}
className
=
"w-full"
>
{
pending
?
"
Salvando...
"
:
"
Resetar senha
"
}
</
Button
>
);
}
const
initialState
:
ActionState
=
{};
export
function
ResetPasswordDialog
({
userId
,
userName
,
}:
{
userId
:
number
;
userName
:
string
;
})
{
const
[
open
,
setOpen
]
=
useState
(
false
);
const
[
password
,
setPassword
]
=
useState
(
""
);
const
[
state
,
formAction
]
=
useFormState
(
resetPasswordAction
,
initialState
);
useEffect
(()
=>
{
if
(
open
&&
state
.
success
)
{
toast
.
success
(
`Senha de
${
userName
}
alterada. Informe a nova senha ao usuário.`
);
setOpen
(
false
);
setPassword
(
""
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[
state
]);
return
(
<
Dialog
open
=
{
open
}
onOpenChange
=
{
setOpen
}
>
<
DialogTrigger
asChild
>
<
Button
variant
=
"outline"
size
=
"sm"
>
<
KeyRound
className
=
"mr-2 h-4 w-4"
/>
Resetar senha
</
Button
>
</
DialogTrigger
>
<
DialogContent
className
=
"sm:max-w-sm"
>
<
DialogHeader
>
<
DialogTitle
>
Resetar senha de
{
userName
}
</
DialogTitle
>
<
DialogDescription
>
Defina a nova senha e repasse ao usuário por um canal seguro.
Recomende que ele a troque depois.
</
DialogDescription
>
</
DialogHeader
>
<
form
action
=
{
formAction
}
className
=
"space-y-4"
>
{
state
.
error
&&
(
<
Alert
variant
=
"destructive"
>
<
AlertDescription
>
{
state
.
error
}
</
AlertDescription
>
</
Alert
>
)
}
<
input
type
=
"hidden"
name
=
"userId"
value
=
{
userId
}
/>
<
div
className
=
"space-y-2"
>
<
Label
htmlFor
=
{
`new-password-
${
userId
}
`
}
>
Nova senha
</
Label
>
<
div
className
=
"flex gap-2"
>
<
Input
id
=
{
`new-password-
${
userId
}
`
}
name
=
"password"
type
=
"text"
minLength
=
{
8
}
required
value
=
{
password
}
onChange
=
{
(
e
)
=>
setPassword
(
e
.
target
.
value
)
}
placeholder
=
"Mínimo 8 caracteres"
autoComplete
=
"off"
/>
<
Button
type
=
"button"
variant
=
"secondary"
size
=
"icon"
title
=
"Gerar senha aleatória"
onClick
=
{
()
=>
setPassword
(
generatePassword
())
}
>
<
RefreshCw
className
=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
div
>
<
SubmitButton
/>
</
form
>
</
DialogContent
>
</
Dialog
>
);
}
src/components/layout/sidebar.tsx
View file @
f85352f0
...
@@ -9,6 +9,7 @@ import {
...
@@ -9,6 +9,7 @@ import {
PanelLeftClose
,
PanelLeftClose
,
PanelLeftOpen
,
PanelLeftOpen
,
Plus
,
Plus
,
ShieldCheck
,
}
from
"
lucide-react
"
;
}
from
"
lucide-react
"
;
import
type
{
Workspace
}
from
"
@/lib/data
"
;
import
type
{
Workspace
}
from
"
@/lib/data
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
import
{
Button
}
from
"
@/components/ui/button
"
;
...
@@ -16,7 +17,13 @@ import { cn } from "@/lib/utils";
...
@@ -16,7 +17,13 @@ import { cn } from "@/lib/utils";
const
STORAGE_KEY
=
"
petruz-sidebar-collapsed
"
;
const
STORAGE_KEY
=
"
petruz-sidebar-collapsed
"
;
export
function
Sidebar
({
workspaces
}:
{
workspaces
:
Workspace
[]
})
{
export
function
Sidebar
({
workspaces
,
isSuperAdmin
=
false
,
}:
{
workspaces
:
Workspace
[];
isSuperAdmin
?:
boolean
;
})
{
const
[
collapsed
,
setCollapsed
]
=
useState
(
false
);
const
[
collapsed
,
setCollapsed
]
=
useState
(
false
);
useEffect
(()
=>
{
useEffect
(()
=>
{
...
@@ -89,6 +96,19 @@ export function Sidebar({ workspaces }: { workspaces: Workspace[] }) {
...
@@ -89,6 +96,19 @@ export function Sidebar({ workspaces }: { workspaces: Workspace[] }) {
<
Folder
className
=
"h-4 w-4 shrink-0"
/>
<
Folder
className
=
"h-4 w-4 shrink-0"
/>
{
!
collapsed
&&
"
Espaços de trabalho
"
}
{
!
collapsed
&&
"
Espaços de trabalho
"
}
</
Link
>
</
Link
>
{
isSuperAdmin
&&
(
<
Link
href
=
"/admin"
title
=
"Administração"
className
=
{
cn
(
"
flex items-center gap-3 rounded-md text-sm font-medium hover:bg-sidebar-accent
"
,
collapsed
?
"
justify-center p-2.5
"
:
"
px-3 py-2
"
)
}
>
<
ShieldCheck
className
=
"h-4 w-4 shrink-0"
/>
{
!
collapsed
&&
"
Administração
"
}
</
Link
>
)
}
{
!
collapsed
&&
(
{
!
collapsed
&&
(
<
p
className
=
"mt-4 px-3 text-xs font-semibold uppercase tracking-wider text-sidebar-foreground/50"
>
<
p
className
=
"mt-4 px-3 text-xs font-semibold uppercase tracking-wider text-sidebar-foreground/50"
>
...
...
src/lib/actions.ts
View file @
f85352f0
...
@@ -11,6 +11,7 @@ import {
...
@@ -11,6 +11,7 @@ import {
}
from
"
./auth
"
;
}
from
"
./auth
"
;
import
{
import
{
assertCanEditTask
,
assertCanEditTask
,
assertSuperAdmin
,
assertWorkspaceAdmin
,
assertWorkspaceAdmin
,
getWorkspaceRole
,
getWorkspaceRole
,
requireUser
,
requireUser
,
...
@@ -103,6 +104,37 @@ export async function logoutAction(): Promise<void> {
...
@@ -103,6 +104,37 @@ export async function logoutAction(): Promise<void> {
redirect
(
"
/login
"
);
redirect
(
"
/login
"
);
}
}
// ---------- Administração (super admin) ----------
const
resetPasswordSchema
=
z
.
object
({
userId
:
z
.
coerce
.
number
().
int
().
positive
(),
password
:
z
.
string
().
min
(
8
,
"
A nova senha deve ter no mínimo 8 caracteres.
"
),
});
export
async
function
resetPasswordAction
(
_prev
:
ActionState
,
formData
:
FormData
):
Promise
<
ActionState
>
{
const
user
=
await
requireUser
();
const
parsed
=
resetPasswordSchema
.
safeParse
({
userId
:
formData
.
get
(
"
userId
"
),
password
:
formData
.
get
(
"
password
"
),
});
if
(
!
parsed
.
success
)
return
{
error
:
firstError
(
parsed
.
error
)
};
try
{
await
assertSuperAdmin
(
user
);
const
hash
=
await
hashPassword
(
parsed
.
data
.
password
);
await
data
.
updateUserPassword
(
parsed
.
data
.
userId
,
hash
);
}
catch
(
err
)
{
return
{
error
:
err
instanceof
Error
?
err
.
message
:
"
Erro ao resetar a senha.
"
,
};
}
revalidatePath
(
"
/admin
"
);
return
{
success
:
true
};
}
// ---------- Workspaces ----------
// ---------- Workspaces ----------
const
workspaceSchema
=
z
.
object
({
const
workspaceSchema
=
z
.
object
({
...
...
src/lib/authz.ts
View file @
f85352f0
...
@@ -19,6 +19,32 @@ export async function requireUser(): Promise<SessionUser> {
...
@@ -19,6 +19,32 @@ export async function requireUser(): Promise<SessionUser> {
return
session
;
return
session
;
}
}
export
const
SUPER_ADMIN_ROLE
=
"
superadmin
"
;
/** Papel global lido do banco — promoções via SQL valem na hora. */
export
async
function
isSuperAdmin
(
userId
:
number
):
Promise
<
boolean
>
{
const
pool
=
await
getPool
();
const
result
=
await
pool
.
request
()
.
input
(
"
id
"
,
sql
.
Int
,
userId
)
.
query
(
"
SELECT Role FROM dbo.Users WHERE Id = @id
"
);
return
result
.
recordset
[
0
]?.
Role
===
SUPER_ADMIN_ROLE
;
}
/** Para páginas: redireciona quem não é super admin. */
export
async
function
requireSuperAdmin
():
Promise
<
SessionUser
>
{
const
user
=
await
requireUser
();
if
(
!
(
await
isSuperAdmin
(
user
.
id
)))
redirect
(
"
/dashboard
"
);
return
user
;
}
/** Para actions: lança erro se não for super admin. */
export
async
function
assertSuperAdmin
(
user
:
SessionUser
):
Promise
<
void
>
{
if
(
!
(
await
isSuperAdmin
(
user
.
id
)))
{
throw
new
Error
(
"
Apenas super admins podem executar esta ação.
"
);
}
}
export
type
WorkspaceRole
=
"
admin
"
|
"
member
"
;
export
type
WorkspaceRole
=
"
admin
"
|
"
member
"
;
export
async
function
getWorkspaceRole
(
export
async
function
getWorkspaceRole
(
...
...
src/lib/data.ts
View file @
f85352f0
...
@@ -85,6 +85,46 @@ export async function createUser(
...
@@ -85,6 +85,46 @@ export async function createUser(
return
result
.
recordset
[
0
];
return
result
.
recordset
[
0
];
}
}
export
interface
UserSummary
{
Id
:
number
;
Name
:
string
;
Email
:
string
;
Role
:
string
;
CreatedAt
:
Date
;
}
/** Papel global atual direto do banco (não confia no token da sessão). */
export
async
function
getUserRole
(
userId
:
number
):
Promise
<
string
|
null
>
{
const
pool
=
await
getPool
();
const
result
=
await
pool
.
request
()
.
input
(
"
id
"
,
sql
.
Int
,
userId
)
.
query
(
"
SELECT Role FROM dbo.Users WHERE Id = @id
"
);
return
result
.
recordset
[
0
]?.
Role
??
null
;
}
export
async
function
listAllUsers
():
Promise
<
UserSummary
[]
>
{
const
pool
=
await
getPool
();
const
result
=
await
pool
.
request
()
.
query
(
"
SELECT Id, Name, Email, Role, CreatedAt FROM dbo.Users ORDER BY Name
"
);
return
result
.
recordset
;
}
export
async
function
updateUserPassword
(
userId
:
number
,
passwordHash
:
string
):
Promise
<
void
>
{
const
pool
=
await
getPool
();
await
pool
.
request
()
.
input
(
"
id
"
,
sql
.
Int
,
userId
)
.
input
(
"
hash
"
,
sql
.
NVarChar
(
255
),
passwordHash
)
.
query
(
"
UPDATE dbo.Users SET PasswordHash = @hash WHERE Id = @id
"
);
}
// ---------- Workspaces ----------
// ---------- Workspaces ----------
export
async
function
listWorkspacesForUser
(
export
async
function
listWorkspacesForUser
(
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment