Ghost

struxadotcloud/struxa

public
Fork0
CodeIssues0Pull requests0
1branch
struxaappswebsrcapp(panel)servers[id]userspage.tsx
style: revert positive/success status indicators to green, keep decorative accents blue4ae2da53 months ago269 lines · 11.9 KB
1"use client";
2
3import { useEffect, useState
4import { use }
5import { useRouter }
6import { useQuery, useMutation
7import { Users, ShieldCheck
8import type { LucideIcon
9import {
10 Dialog,
11 DialogPopup,
12 DialogHeader,
13 DialogFooter,
14 DialogTitle,
15 DialogDescription,
16 DialogClose,
17} from "@struxa/ui/components/dialog";
18import { useTranslations }
19import { toast }
20import { orpc }
21import { authClient }
22import Loader from "@/components/loader
23
24function StatRow({ icon
25 return (
26 <div className="flex flex-col border-b border-border last:border-b-0
27 <div className="flex flex-col gap-1.5 px-4 pt-3 pb-2.5
28 <div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground
29 <Icon className="
30 {label}
31 </div>
32 {children}
33 </div>
34 </div>
35 );
36}
37
38const ALL_PERMISSIONS = [
39 "console", "files",
40 "schedules", "users",
41] as const;
42
43type Permission
44
45function PermissionPill({ perm
46 return (
47 <span className={`
48 active ? "bg-green-500/10 text-green-600 dark:text-green-400"
49 }`}>
50 {perm}
51 </span>
52 );
53}
54
55export default function UsersPage
56 const t =
57 const tc =
58 const router =
59 const { data:
60 const { id
61 const queryClient =
62 const [showInvite,
63 const [inviteEmail,
64 const [invitePermissions,
65
66 useEffect(() =>
67 if (!isPending &&
68 }, [isPending,
69
70 const { data:
71 const serverId =
72
73 const { data:
74 ...orpc.subusers.
75 enabled: !!serverId,
76 });
77
78 const createMutation =
79 const deleteMutation =
80 ...orpc.subusers.
81 onSuccess: () =>
82 void queryClient.invalidateQueries(
83 toast.success(tc
84 },
85 });
86
87 function closeInvite() {
88 setShowInvite(false);
89 setInviteEmail("");
90 setInvitePermissions([]);
91 }
92
93 async function handleInvite
94 if (!serverId ||
95 await createMutation.mutateAsync
96 void queryClient.invalidateQueries(
97 toast.success(t
98 closeInvite();
99 }
100
101 function togglePerm(perm
102 setInvitePermissions((prev)
103 prev.includes(perm
104 );
105 }
106
107 if (isPending ||
108
109 return (
110 <>
111 <Dialog open={
112 <DialogPopup showCloseButton={
113 <DialogHeader>
114 <DialogTitle>{t(
115 <DialogDescription>{t(
116 </DialogHeader>
117 <div className="flex flex-col gap-4 px-5 py-4
118 <div>
119 <label className="mb-1.5 block text-xs font-medium text-foreground
120 <input
121 autoFocus
122 type="email"
123 value={inviteEmail}
124 onChange={(e)
125 placeholder="user@example.com"
126 className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground/50 focus:border-ring transition-colors"
127 onKeyDown={(e)
128 />
129 </div>
130 <div>
131 <div className="mb-2 flex items-center justify-between
132 <label className="text-xs font-medium text-foreground
133 <button
134 type="button"
135 onClick={() =>
136 className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
137 >
138 {invitePermissions.length ===
139 </button>
140 </div>
141 <div className="grid grid-cols-2 gap-1 sm:grid-cols-3
142 {ALL_PERMISSIONS.map((
143 const active =
144 return (
145 <button
146 key={perm}
147 type="button"
148 onClick={() =>
149 className={`flex items-center justify-between rounded-lg border px-3 py-2 text-xs font-medium transition-colors ${
150 active
151 ? "border-green-500/30 bg-green-500/10 text-green-600 dark:text-green-400"
152 : "border-border bg-muted/30 text-muted-foreground hover:bg-muted hover:text-foreground"
153 }`}
154 >
155 <span className="capitalize
156 <span className={`
157 </button>
158 );
159 })}
160 </div>
161 </div>
162 </div>
163 <DialogFooter>
164 <DialogClose
165 className="rounded-lg px-4 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
166 disabled={createMutation.isPending
167 >
168 {t("cancel")
169 </DialogClose>
170 <button
171 type="button"
172 onClick={() =>
173 disabled={!inviteEmail.
174 className="rounded-lg bg-foreground px-4 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80 disabled:opacity-40"
175 >
176 {createMutation.isPending ?
177 </button>
178 </DialogFooter>
179 </DialogPopup>
180 </Dialog>
181
182 <div className="flex flex-1 flex-col gap-3 overflow-auto px-4 py-4 md:flex-row md:overflow-hidden
183 <div className="flex flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card
184 <div className="flex items-center justify-between border-b border-border px-4 py-3
185 <p className="text-sm font-medium text-foreground
186 <button
187 type="button"
188 onClick={() =>
189 className="flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
190 >
191 <UserPlus className="
192 {t("inviteUser")
193 </button>
194 </div>
195 <div className="grid grid-cols-[1fr_auto] border-b border-border bg-muted/40 px-4 py-2.5
196 <span className="text-xs font-medium text-muted-foreground
197 <span className="text-xs font-medium text-muted-foreground
198 </div>
199 <div className="flex-1 overflow-y-auto
200 {subusersPending ? (
201 <div className="flex items-center justify-center py-12 text-sm text-muted-foreground
202 ) : subusers.length
203 <div className="flex flex-col items-center justify-center py-12 gap-2
204 <Users className="
205 <p className="text-sm text-muted-foreground
206 <button
207 type="button"
208 onClick={() =>
209 className="mt-1 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
210 >
211 {t("inviteFirst")
212 </button>
213 </div>
214 ) : (
215 subusers.map((sub
216 const user =
217 const permissions:
218 try { return JSON
219 })();
220 const isLast =
221 return (
222 <div key={sub
223 <div className="flex items-center justify-between px-4 py-3 hover:bg-muted/40 transition-colors
224 <div className="flex items-center gap-3
225 <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground uppercase
226 {(user?.name
227 </div>
228 <div>
229 <span className="text-sm font-medium text-foreground
230 <div className="text-xs text-muted-foreground
231 </div>
232 </div>
233 <button
234 type="button"
235 onClick={() =>
236 disabled={deleteMutation.isPending
237 className="rounded p-1 text-muted-foreground/50 hover:bg-muted hover:text-destructive disabled:opacity-30 transition-colors"
238 >
239 <Trash2 className="
240 </button>
241 </div>
242 <div className="flex flex-wrap gap-1.5 border-t border-border/60 bg-muted/20 px-4 py-2.5
243 {ALL_PERMISSIONS.map((
244 <PermissionPill key={
245 ))}
246 </div>
247 </div>
248 );
249 })
250 )}
251 </div>
252 </div>
253
254 <aside className="flex w-full shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-card md:w-[220px]
255 <div className="overflow-y-auto
256 <StatRow icon={
257 <span className="text-xl font-bold text-foreground
258 </StatRow>
259 <StatRow icon={
260 <p className="text-xs text-muted-foreground leading-relaxed
261 {t("statNoteText")
262 </p>
263 </StatRow>
264 </div>
265 </aside>
266 </div>
267 </>
268 );
269}
}
from
"
react
"
;
from
"
react
"
;
from
"
next/navigation
"
;
,
useQueryClient
}
from
"
@tanstack/react-query
"
;
,
UserPlus
,
Trash2
}
from
"
lucide-react
"
;
}
from
"
lucide-react
"
;
from
"
next-intl
"
;
from
"
sonner
"
;
from
"
@/utils/orpc
"
;
from
"
@/lib/auth-client
"
;
"
;
:
Icon
,
label
,
children
}
:
{
icon
:
LucideIcon
;
label
:
string
;
children
:
React
.
ReactNode
})
{
"
>
"
>
"
>
h-3 w-3
"
/>
"
backups
"
,
"
databases
"
,
"
network
"
,
"
startup
"
,
"
settings
"
,
=
typeof
ALL_PERMISSIONS
[
number
]
;
,
active
}
:
{
perm
:
Permission
;
active
:
boolean
})
{
rounded-full px-2 py-0.5 text-[11px] font-medium
${
:
"
bg-muted text-muted-foreground
"
(
{
params
}
:
{
params
:
Promise
<
{
id
:
string
}
>
})
{
useTranslations
(
"
panel.users
"
)
;
useTranslations
(
"
common
"
)
;
useRouter
()
;
session
,
isPending
}
=
authClient
.
useSession
()
;
}
=
use
(
params
)
;
useQueryClient
()
;
setShowInvite
]
=
useState
(
false
)
;
setInviteEmail
]
=
useState
(
""
)
;
setInvitePermissions
]
=
useState
<
Permission
[]
>
([])
;
{
!
session
)
router
.
replace
(
"
/login
"
)
;
session
,
router
])
;
server
}
=
useQuery
(
orpc
.
servers
.
get
.
queryOptions
(
{
input
:
{
id
}
}
))
;
server
?.
id
;
subusers
=
[]
,
isPending
:
subusersPending
}
=
useQuery
(
{
list
.
queryOptions
(
{
input
:
{
serverId
:
serverId
??
""
}
}
)
,
useMutation
(
orpc
.
subusers
.
create
.
mutationOptions
())
;
useMutation
(
{
delete
.
mutationOptions
()
,
{
orpc
.
subusers
.
list
.
queryOptions
(
{
input
:
{
serverId
:
serverId
??
""
}
}
))
;
(
"
deleted
"
))
;
()
{
!
inviteEmail
.
trim
())
return
;
(
{
serverId
,
email
:
inviteEmail
.
trim
()
,
permissions
:
invitePermissions
}
)
;
orpc
.
subusers
.
list
.
queryOptions
(
{
input
:
{
serverId
}
}
))
;
(
"
inviteSuccess
"
))
;
:
Permission
)
{
=>
)
?
prev
.
filter
(
(
p
)
=>
p
!==
perm
)
:
[
...
prev
,
perm
]
!
session
)
return
<
Loader
/>
;
showInvite
}
onOpenChange
=
{
(
open
)
=>
{
if
(
!
open
)
closeInvite
()
;
}
}
>
false
}
>
"
inviteTitle
"
)
}
</
DialogTitle
>
"
inviteDescription
"
)
}
</
DialogDescription
>
"
>
"
>
{
t
(
"
emailLabel
"
)
}
</
label
>
=>
setInviteEmail
(
e
.
target
.
value
)
}
=>
{
if
(
e
.
key
===
"
Enter
"
)
void
handleInvite
()
;
if
(
e
.
key
===
"
Escape
"
)
closeInvite
()
;
}
}
"
>
"
>
{
t
(
"
permissionsLabel
"
)
}
</
label
>
setInvitePermissions
(
invitePermissions
.
length
===
ALL_PERMISSIONS
.
length
?
[]
:
[
...
ALL_PERMISSIONS
])
}
ALL_PERMISSIONS
.
length
?
t
(
"
deselectAll
"
)
:
t
(
"
selectAll
"
)
}
"
>
perm
)
=>
{
invitePermissions
.
includes
(
perm
)
;
togglePerm
(
perm
)
}
"
>
{
perm
}
</
span
>
h-2 w-2 shrink-0 rounded-full
${
active
?
"
bg-green-500
"
:
"
bg-muted-foreground/30
"
}
`
}
/>
}
}
void
handleInvite
()
}
trim
()
||
createMutation
.
isPending
}
t
(
"
inviting
"
)
:
t
(
"
inviteUser
"
)
}
"
>
"
>
"
>
"
>
{
t
(
"
sectionTitle
"
)
}
</
p
>
setShowInvite
(
true
)
}
h-3.5 w-3.5
"
/>
}
"
>
"
>
{
t
(
"
colUser
"
)
}
</
span
>
"
>
{
t
(
"
colActions
"
)
}
</
span
>
"
>
"
>
{
t
(
"
loading
"
)
}
</
div
>
===
0
?
(
"
>
h-8 w-8 text-muted-foreground/30
"
/>
"
>
{
t
(
"
empty
"
)
}
</
p
>
setShowInvite
(
true
)
}
}
,
i
)
=>
{
sub
.
user
as
{
name
:
string
|
null
;
email
:
string
}
|
undefined
;
string
[]
=
(
()
=>
{
.
parse
(
sub
.
permissions
)
as
string
[]
;
}
catch
{
return
[]
;
}
i
===
subusers
.
length
-
1
;
.
id
}
className
=
{
!
isLast
?
"
border-b border-border
"
:
""
}
>
"
>
"
>
"
>
??
user
?.
email
??
"
?
"
)[
0
]
}
"
>
{
user
?.
name
??
user
?.
email
??
"
Unknown
"
}
</
span
>
"
>
{
user
?.
email
??
sub
.
userId
}
</
div
>
serverId
&&
deleteMutation
.
mutate
(
{
serverId
,
userId
:
sub
.
userId
}
)
}
}
h-3.5 w-3.5
"
/>
"
>
perm
)
=>
(
perm
}
perm
=
{
perm
}
active
=
{
permissions
.
includes
(
perm
)
}
/>
"
>
"
>
Users
}
label
=
{
t
(
"
statSubusers
"
)
}
>
"
>
{
subusers
.
length
}
</
span
>
ShieldCheck
}
label
=
{
t
(
"
statNote
"
)
}
>
"
>
}