Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web/apps/client-demo/src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Organization from './pages/Organization';
import Settings from './pages/Settings';
import General from './pages/settings/General';
import Preferences from './pages/settings/Preferences';
import Profile from './pages/settings/Profile';

function Router() {
return (
Expand All @@ -26,6 +27,7 @@ function Router() {
<Route path="/:orgId/settings" element={<Settings />}>
<Route path="general" element={<General />} />
<Route path="preferences" element={<Preferences />} />
<Route path="profile" element={<Profile />} />
</Route>
Comment on lines +27 to +31
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Protect the new settings route with the same auth guard pattern used elsewhere.

This route currently depends on Settings for enforcement, and the provided web/apps/client-demo/src/pages/Settings.tsx snippet has no auth redirect. Please gate /:orgId/settings to prevent unauthenticated access to the settings shell.

<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
Expand Down
3 changes: 2 additions & 1 deletion web/apps/client-demo/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { useFrontier } from '@raystack/frontier/react';

const NAV_ITEMS = [
{ label: 'General', path: 'general' },
{ label: 'Preferences', path: 'preferences' }
{ label: 'Preferences', path: 'preferences' },
{ label: 'Profile', path: 'profile' }
];

export default function Settings() {
Expand Down
5 changes: 5 additions & 0 deletions web/apps/client-demo/src/pages/settings/Profile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ProfileView } from '@raystack/frontier/react';

export default function Profile() {
return <ProfileView />;
}
1 change: 1 addition & 0 deletions web/sdk/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export { ViewContainer } from './components/view-container';
export { ViewHeader } from './components/view-header';
export { GeneralView } from './views-new/general';
export { PreferencesView, PreferenceRow } from './views-new/preferences';
export { ProfileView } from './views-new/profile';

export type {
FrontierClientOptions,
Expand Down
1 change: 1 addition & 0 deletions web/sdk/react/views-new/profile/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ProfileView } from './profile-view';
8 changes: 8 additions & 0 deletions web/sdk/react/views-new/profile/profile-view.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.section {
padding: var(--rs-space-9) 0;
border-bottom: 1px solid var(--rs-color-border-base-primary);
}

.formFields {
max-width: 320px;
}
185 changes: 185 additions & 0 deletions web/sdk/react/views-new/profile/profile-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
'use client';

import { useEffect } from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import { useForm } from 'react-hook-form';
import * as yup from 'yup';
import {
createConnectQueryKey,
useMutation
} from '@connectrpc/connect-query';
import { FrontierServiceQueries } from '@raystack/proton/frontier';
import { useQueryClient } from '@tanstack/react-query';
import {
Button,
Flex,
InputField,
Skeleton,
toastManager
} from '@raystack/apsara-v1';
import { useFrontier } from '../../contexts/FrontierContext';
import { ViewContainer } from '../../components/view-container';
import { ViewHeader } from '../../components/view-header';
import { ImageUpload } from '../../components/image-upload';
import styles from './profile-view.module.css';

const profileSchema = yup
.object({
avatar: yup.string().optional(),
title: yup
.string()
.required('Name is required')
.min(2, 'Name must be at least 2 characters')
.matches(
/^[\p{L} .'-]+$/u,
'Name can only contain letters, spaces, periods, hyphens, and apostrophes'
)
.matches(/^\p{L}/u, 'Name must start with a letter')
.matches(
/^\p{L}[\p{L} .'-]*\p{L}$|^\p{L}$/u,
'Name must end with a letter'
)
.matches(/^(?!.* {2}).*$/, 'Name cannot have consecutive spaces')
.matches(/^(?!.* [^\p{L}]).*$/u, 'Spaces must be followed by a letter')
.matches(/^(?!.*-[^\p{L}]).*$/u, 'Hyphens must be followed by a letter')
.matches(
/^(?!.*'[^\p{L}]).*$/u,
'Apostrophes must be followed by a letter'
),
email: yup.string().email().required()
})
.required();

type FormData = yup.InferType<typeof profileSchema>;

export function ProfileView() {
const { user, isUserLoading: isLoading } = useFrontier();
const queryClient = useQueryClient();

const { mutateAsync: updateCurrentUser } = useMutation(
FrontierServiceQueries.updateCurrentUser,
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: createConnectQueryKey({
schema: FrontierServiceQueries.getCurrentUser,
cardinality: 'finite'
})
});
}
}
);

const {
reset,
register,
handleSubmit,
watch,
setValue,
formState: { errors, isSubmitting, isDirty }
} = useForm({
resolver: yupResolver(profileSchema)
});

useEffect(() => {
reset(user, { keepDirtyValues: true });
}, [user, reset]);

async function onSubmit(data: FormData) {
try {
if (!user?.id) return;
await updateCurrentUser({ body: data });
toastManager.add({ title: 'Updated user', type: 'success' });
} catch (err: unknown) {
toastManager.add({
title: 'Something went wrong',
description: err instanceof Error ? err.message : 'Failed to update',
type: 'error'
});
}
}

return (
<ViewContainer>
<ViewHeader
title="Profile"
description="Manage your profile information and settings."
/>

<form onSubmit={handleSubmit(onSubmit)}>
<Flex direction="column">
{/* Avatar section */}
<Flex direction="column" gap={5} className={styles.section}>
{isLoading ? (
<Flex direction="column" gap={5}>
<Skeleton width="72px" height="72px" />
<Skeleton height="20px" width="50%" />
</Flex>
) : (
<ImageUpload
value={watch('avatar')}
onChange={(value: string) =>
setValue('avatar', value, { shouldDirty: true })
}
description="Pick a profile picture for your avatar. Max size: 5 Mb"
initials={user?.title?.[0]}
data-test-id="frontier-sdk-profile-avatar-upload"
/>
)}
</Flex>

{/* Form section */}
<Flex direction="column" gap={7} className={styles.section}>
<Flex direction="column" gap={9} className={styles.formFields}>
{isLoading ? (
<>
<Skeleton height="58px" />
<Skeleton height="58px" />
</>
) : (
<>
<InputField
label="Full name"
size="large"
error={errors.title && String(errors.title?.message)}
defaultValue={user?.title || ''}
placeholder="Provide full name"
{...register('title')}
disabled={isLoading}
/>
<InputField
label="Email address"
size="large"
error={errors.email && String(errors.email?.message)}
value={user?.email || ''}
type="email"
placeholder="Provide email address"
{...register('email')}
readOnly
disabled
/>
</>
)}
</Flex>

{isLoading ? (
<Skeleton height="32px" width="64px" />
) : (
<Button
type="submit"
variant="solid"
color="accent"
disabled={isLoading || isSubmitting || !isDirty}
loading={isSubmitting}
loaderText="Updating..."
data-test-id="frontier-sdk-update-user-btn"
>
Update
</Button>
)}
</Flex>
</Flex>
</form>
</ViewContainer>
);
}