@@ -5,20 +5,27 @@ import {
55 Card ,
66 CardDescription ,
77 CardTitle ,
8+ Dialog ,
9+ DialogContent ,
10+ DialogFooter ,
11+ DialogHeader ,
12+ DialogTitle ,
813 Input ,
914 Select ,
1015} from "@cap/ui" ;
1116import { type ImageUpload , Organisation } from "@cap/web-domain" ;
1217import { useMutation } from "@tanstack/react-query" ;
1318import { Effect , Option } from "effect" ;
19+ import { LogOut } from "lucide-react" ;
1420import { useRouter } from "next/navigation" ;
21+ import { signOut } from "next-auth/react" ;
1522import { useEffect , useId , useState } from "react" ;
1623import { toast } from "sonner" ;
1724import { SignedImageUrl } from "@/components/SignedImageUrl" ;
1825import { useEffectMutation , useRpcClient } from "@/lib/EffectRuntime" ;
1926import { useDashboardContext } from "../../Contexts" ;
2027import { ProfileImage } from "./components/ProfileImage" ;
21- import { patchAccountSettings } from "./server" ;
28+ import { patchAccountSettings , signOutAllDevices } from "./server" ;
2229
2330export const Settings = ( ) => {
2431 const router = useRouter ( ) ;
@@ -28,6 +35,7 @@ export const Settings = () => {
2835 const [ defaultOrgId , setDefaultOrgId ] = useState <
2936 Organisation . OrganisationId | undefined
3037 > ( user ?. defaultOrgId || undefined ) ;
38+ const [ signOutAllDevicesOpen , setSignOutAllDevicesOpen ] = useState ( false ) ;
3139 const firstNameId = useId ( ) ;
3240 const lastNameId = useId ( ) ;
3341 const contactEmailId = useId ( ) ;
@@ -72,6 +80,18 @@ export const Settings = () => {
7280 } ,
7381 } ) ;
7482
83+ const signOutAllDevicesMutation = useMutation ( {
84+ mutationFn : signOutAllDevices ,
85+ onSuccess : ( ) => {
86+ toast . success ( "Signed out of all devices" ) ;
87+ setSignOutAllDevicesOpen ( false ) ;
88+ signOut ( { callbackUrl : "/login" } ) ;
89+ } ,
90+ onError : ( ) => {
91+ toast . error ( "Failed to sign out of all devices" ) ;
92+ } ,
93+ } ) ;
94+
7595 // Prevent navigation when there are unsaved changes
7696 useEffect ( ( ) => {
7797 const handleBeforeUnload = ( e : BeforeUnloadEvent ) => {
@@ -153,117 +173,184 @@ export const Settings = () => {
153173 } ;
154174
155175 return (
156- < form
157- onSubmit = { ( e ) => {
158- e . preventDefault ( ) ;
159- updateName ( ) ;
160- } }
161- >
162- < div className = "grid gap-6 w-full md:grid-cols-2" >
163- < Card className = "space-y-4" >
164- < div className = "space-y-1" >
165- < CardTitle > Profile image</ CardTitle >
166- < CardDescription >
167- This image appears in your profile, comments, and shared caps.
168- </ CardDescription >
169- </ div >
170- < ProfileImage
171- initialPreviewUrl = { profileImagePreviewUrl }
172- onChange = { handleProfileImageChange }
173- onRemove = { handleProfileImageRemove }
174- disabled = { isProfileImageMutating }
175- isUploading = { uploadProfileImageMutation . isPending }
176- isRemoving = { removeProfileImageMutation . isPending }
177- userName = { user ?. name }
178- />
179- </ Card >
180- < Card className = "space-y-4" >
181- < div className = "space-y-1" >
182- < CardTitle > Your name</ CardTitle >
183- < CardDescription >
184- Changing your name below will update how your name appears when
185- sharing a Cap, and in your profile.
186- </ CardDescription >
187- </ div >
188- < div className = "flex flex-col flex-wrap gap-3 w-full" >
189- < div className = "flex-1" >
190- < Input
191- type = "text"
192- placeholder = "First name"
193- onChange = { ( e ) => setFirstName ( e . target . value ) }
194- defaultValue = { firstName as string }
195- id = { firstNameId }
196- name = "firstName"
197- />
176+ < >
177+ < form
178+ onSubmit = { ( e ) => {
179+ e . preventDefault ( ) ;
180+ updateName ( ) ;
181+ } }
182+ >
183+ < div className = "grid gap-6 w-full md:grid-cols-2" >
184+ < Card className = "space-y-4" >
185+ < div className = "space-y-1" >
186+ < CardTitle > Profile image</ CardTitle >
187+ < CardDescription >
188+ This image appears in your profile, comments, and shared caps.
189+ </ CardDescription >
198190 </ div >
199- < div className = "flex-1 space-y-2" >
200- < Input
201- type = "text"
202- placeholder = "Last name"
203- onChange = { ( e ) => setLastName ( e . target . value ) }
204- defaultValue = { lastName as string }
205- id = { lastNameId }
206- name = "lastName"
207- />
191+ < ProfileImage
192+ initialPreviewUrl = { profileImagePreviewUrl }
193+ onChange = { handleProfileImageChange }
194+ onRemove = { handleProfileImageRemove }
195+ disabled = { isProfileImageMutating }
196+ isUploading = { uploadProfileImageMutation . isPending }
197+ isRemoving = { removeProfileImageMutation . isPending }
198+ userName = { user ?. name }
199+ />
200+ </ Card >
201+ < Card className = "space-y-4" >
202+ < div className = "space-y-1" >
203+ < CardTitle > Your name</ CardTitle >
204+ < CardDescription >
205+ Changing your name below will update how your name appears when
206+ sharing a Cap, and in your profile.
207+ </ CardDescription >
208208 </ div >
209- </ div >
210- </ Card >
211- < Card className = "flex flex-col gap-4" >
212- < div className = "space-y-1" >
213- < CardTitle > Contact email address</ CardTitle >
214- < CardDescription >
215- This is the email address you used to sign up to Cap with.
216- </ CardDescription >
217- </ div >
218- < Input
219- type = "email"
220- value = { user ?. email as string }
221- id = { contactEmailId }
222- name = "contactEmail"
223- disabled
224- />
225- </ Card >
226- < Card className = "flex flex-col gap-4" >
227- < div className = "space-y-1" >
228- < CardTitle > Default organization</ CardTitle >
229- < CardDescription > This is the default organization</ CardDescription >
230- </ div >
231-
232- < Select
233- placeholder = "Default organization"
234- value = {
235- defaultOrgId ??
236- user ?. defaultOrgId ??
237- organizationData ?. [ 0 ] ?. organization . id ??
238- ""
239- }
240- onValueChange = { ( value ) =>
241- setDefaultOrgId ( Organisation . OrganisationId . make ( value ) )
242- }
243- options = { ( organizationData || [ ] ) . map ( ( org ) => ( {
244- value : org . organization . id ,
245- label : org . organization . name ,
246- image : (
247- < SignedImageUrl
248- className = "size-5"
249- image = { org . organization . iconUrl }
250- name = { org . organization . name }
209+ < div className = "flex flex-col flex-wrap gap-3 w-full" >
210+ < div className = "flex-1" >
211+ < Input
212+ type = "text"
213+ placeholder = "First name"
214+ onChange = { ( e ) => setFirstName ( e . target . value ) }
215+ defaultValue = { firstName as string }
216+ id = { firstNameId }
217+ name = "firstName"
218+ />
219+ </ div >
220+ < div className = "flex-1 space-y-2" >
221+ < Input
222+ type = "text"
223+ placeholder = "Last name"
224+ onChange = { ( e ) => setLastName ( e . target . value ) }
225+ defaultValue = { lastName as string }
226+ id = { lastNameId }
227+ name = "lastName"
251228 />
252- ) ,
253- } ) ) }
254- />
255- </ Card >
256- </ div >
257- < Button
258- disabled = { ! firstName || updateNamePending || ! hasChanges }
259- className = "mt-6"
260- type = "submit"
261- size = "sm"
262- variant = "dark"
263- spinner = { updateNamePending }
229+ </ div >
230+ </ div >
231+ </ Card >
232+ < Card className = "flex flex-col gap-4" >
233+ < div className = "space-y-1" >
234+ < CardTitle > Contact email address</ CardTitle >
235+ < CardDescription >
236+ This is the email address you used to sign up to Cap with.
237+ </ CardDescription >
238+ </ div >
239+ < Input
240+ type = "email"
241+ value = { user ?. email as string }
242+ id = { contactEmailId }
243+ name = "contactEmail"
244+ disabled
245+ />
246+ </ Card >
247+ < Card className = "flex flex-col gap-4" >
248+ < div className = "space-y-1" >
249+ < CardTitle > Default organization</ CardTitle >
250+ < CardDescription >
251+ This is the default organization
252+ </ CardDescription >
253+ </ div >
254+
255+ < Select
256+ placeholder = "Default organization"
257+ value = {
258+ defaultOrgId ??
259+ user ?. defaultOrgId ??
260+ organizationData ?. [ 0 ] ?. organization . id ??
261+ ""
262+ }
263+ onValueChange = { ( value ) =>
264+ setDefaultOrgId ( Organisation . OrganisationId . make ( value ) )
265+ }
266+ options = { ( organizationData || [ ] ) . map ( ( org ) => ( {
267+ value : org . organization . id ,
268+ label : org . organization . name ,
269+ image : (
270+ < SignedImageUrl
271+ className = "size-5"
272+ image = { org . organization . iconUrl }
273+ name = { org . organization . name }
274+ />
275+ ) ,
276+ } ) ) }
277+ />
278+ </ Card >
279+ </ div >
280+ < Button
281+ disabled = { ! firstName || updateNamePending || ! hasChanges }
282+ className = "mt-6"
283+ type = "submit"
284+ size = "sm"
285+ variant = "dark"
286+ spinner = { updateNamePending }
287+ >
288+ { updateNamePending ? "Saving..." : "Save" }
289+ </ Button >
290+ </ form >
291+ < Card className = "flex flex-col gap-4 mt-6 md:flex-row md:items-center md:justify-between" >
292+ < div className = "space-y-1" >
293+ < CardTitle > Sign out of all devices</ CardTitle >
294+ < CardDescription >
295+ Invalidate every Cap web session and desktop app authentication
296+ token connected to your account.
297+ </ CardDescription >
298+ </ div >
299+ < Button
300+ type = "button"
301+ size = "sm"
302+ variant = "destructive"
303+ icon = { < LogOut className = "size-4" /> }
304+ onClick = { ( ) => setSignOutAllDevicesOpen ( true ) }
305+ >
306+ Sign out all devices
307+ </ Button >
308+ </ Card >
309+ < Dialog
310+ open = { signOutAllDevicesOpen }
311+ onOpenChange = { setSignOutAllDevicesOpen }
264312 >
265- { updateNamePending ? "Saving..." : "Save" }
266- </ Button >
267- </ form >
313+ < DialogContent >
314+ < DialogHeader
315+ icon = { < LogOut className = "size-4" /> }
316+ description = "This will immediately invalidate existing Cap web sessions, desktop session tokens, and desktop API keys for your account."
317+ >
318+ < DialogTitle > Sign out of all devices?</ DialogTitle >
319+ </ DialogHeader >
320+ < div className = "p-5 space-y-3 text-sm text-gray-11" >
321+ < p >
322+ You will be signed out of this browser after the reset completes.
323+ </ p >
324+ < p >
325+ The Cap desktop app may need you to click Sign out, then sign in
326+ again before uploads and settings sync work.
327+ </ p >
328+ </ div >
329+ < DialogFooter >
330+ < Button
331+ type = "button"
332+ size = "sm"
333+ variant = "gray"
334+ onClick = { ( ) => setSignOutAllDevicesOpen ( false ) }
335+ >
336+ Cancel
337+ </ Button >
338+ < Button
339+ type = "button"
340+ size = "sm"
341+ variant = "destructive"
342+ icon = { < LogOut className = "size-4" /> }
343+ onClick = { ( ) => signOutAllDevicesMutation . mutate ( ) }
344+ spinner = { signOutAllDevicesMutation . isPending }
345+ disabled = { signOutAllDevicesMutation . isPending }
346+ >
347+ { signOutAllDevicesMutation . isPending
348+ ? "Signing out..."
349+ : "Sign out all devices" }
350+ </ Button >
351+ </ DialogFooter >
352+ </ DialogContent >
353+ </ Dialog >
354+ </ >
268355 ) ;
269356} ;
0 commit comments