Skip to content
Merged
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
198 changes: 198 additions & 0 deletions client/src/components/profile/EditProfileDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { useState, type ReactNode, type SubmitEvent } from 'react';
import * as RadixDialog from '@radix-ui/react-dialog';
import { useMutation } from '@apollo/client/react';
import { Button } from '../ui/Button';
import { Field } from '../ui/Field';
import { Input } from '../ui/Input';
import { useCurrentUser } from '../../lib/useCurrentUser';
import { CHANGE_PASSWORD, UPDATE_PROFILE } from '../../graphql/auth';

// passordet må være minst så mange tegn
const MIN_PASSWORD_LENGTH = 6;

type EditProfileDialogProps = {
trigger: ReactNode;
};

// popup der brukeren kan endre navn og passord
export function EditProfileDialog({ trigger }: EditProfileDialogProps) {
const { user } = useCurrentUser();

const [name, setName] = useState(user?.name ?? '');
const [nameMessage, setNameMessage] = useState('');

const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [repeatPassword, setRepeatPassword] = useState('');
const [passwordMessage, setPasswordMessage] = useState('');

const [updateProfile, { loading: savingName }] = useMutation(UPDATE_PROFILE);
const [changePassword, { loading: savingPassword }] =
useMutation(CHANGE_PASSWORD);

// tømmer feltene når popupen lukkes
function handleOpenChange(open: boolean) {
if (!open) {
setName(user?.name ?? '');
setNameMessage('');
setCurrentPassword('');
setNewPassword('');
setRepeatPassword('');
setPasswordMessage('');
}
}

async function saveName(event: SubmitEvent<HTMLFormElement>) {
// hindrer at siden lastes på nytt
event.preventDefault();

if (!name.trim()) {
setNameMessage('Navnet kan ikke være tomt.');
return;
}

try {
await updateProfile({ variables: { name: name.trim() } });
setNameMessage('Navnet er lagret.');
} catch (error) {
setNameMessage(
error instanceof Error ? error.message : 'Kunne ikke lagre navnet.',
);
}
}

async function savePassword(event: SubmitEvent<HTMLFormElement>) {
event.preventDefault();

if (!currentPassword || !newPassword || !repeatPassword) {
setPasswordMessage('Fyll inn alle feltene.');
return;
}

if (newPassword.length < MIN_PASSWORD_LENGTH) {
setPasswordMessage(
`Nytt passord må være minst ${MIN_PASSWORD_LENGTH} tegn.`,
);
return;
}

if (newPassword !== repeatPassword) {
setPasswordMessage('Passordene er ikke like.');
return;
}

try {
await changePassword({ variables: { currentPassword, newPassword } });
setCurrentPassword('');
setNewPassword('');
setRepeatPassword('');
setPasswordMessage('Passordet er byttet.');
} catch (error) {
setPasswordMessage(
error instanceof Error ? error.message : 'Kunne ikke bytte passord.',
);
}
}

return (
<RadixDialog.Root onOpenChange={handleOpenChange}>
<RadixDialog.Trigger asChild>{trigger}</RadixDialog.Trigger>
<RadixDialog.Portal>
<RadixDialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
{/* popup som kan scrolles på små skjermer */}
<RadixDialog.Content className="fixed top-1/2 left-1/2 z-50 max-h-[92vh] w-[94%] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-large bg-background p-6 text-text shadow-lg focus:outline-none">
<RadixDialog.Title className="font-heading text-3xl">
Rediger profil
</RadixDialog.Title>
<RadixDialog.Description className="mt-2">
Endre navnet ditt eller bytt passord.
</RadixDialog.Description>

{/* navn */}
<form onSubmit={saveName} className="mt-6 flex flex-col gap-4">
<Field label="Navn" htmlFor="profile-name">
<Input
id="profile-name"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={50}
autoComplete="name"
/>
</Field>
{nameMessage && (
<p role="status" className="font-bold text-primary">
{nameMessage}
</p>
)}
<Button
type="submit"
variant="primary"
disabled={savingName}
className="font-bold"
>
Lagre navn
</Button>
</form>

{/* passord */}
<form
onSubmit={savePassword}
className="mt-8 flex flex-col gap-4 border-t border-peach pt-6"
>
<h3 className="font-heading text-2xl">Bytt passord</h3>
<Field label="Nåværende passord" htmlFor="current-password">
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
/>
</Field>
<Field label="Nytt passord" htmlFor="new-password">
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
placeholder={`Minst ${MIN_PASSWORD_LENGTH} tegn`}
autoComplete="new-password"
/>
</Field>
<Field label="Gjenta nytt passord" htmlFor="repeat-new-password">
<Input
id="repeat-new-password"
type="password"
value={repeatPassword}
onChange={(event) => setRepeatPassword(event.target.value)}
autoComplete="new-password"
/>
</Field>
{passwordMessage && (
<p role="status" className="font-bold text-primary">
{passwordMessage}
</p>
)}
<Button
type="submit"
variant="primary"
disabled={savingPassword}
className="font-bold"
>
Bytt passord
</Button>
</form>

<RadixDialog.Close asChild>
<Button
variant="secondary"
className="mt-6 w-full font-bold hover:bg-primary/10!"
>
Lukk
</Button>
</RadixDialog.Close>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
}
24 changes: 23 additions & 1 deletion client/src/graphql/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ export const CONTINUE_AS_GUEST = gql`
${USER_FIELDS}
`;


// lagrer språk, måleenheter og allergier
export const UPDATE_PREFERENCES: TypedDocumentNode<
{ updatePreferences: BackendUser },
Expand All @@ -85,3 +84,26 @@ export const UPDATE_PREFERENCES: TypedDocumentNode<
}
${USER_FIELDS}
`;

// endrer navnet som vises på profilen
export const UPDATE_PROFILE: TypedDocumentNode<
{ updateProfile: BackendUser },
{ name: string }
> = gql`
mutation UpdateProfile($name: String!) {
updateProfile(name: $name) {
...UserFields
}
}
${USER_FIELDS}
`;

// bytter passord hvis det gamle stemmer
export const CHANGE_PASSWORD: TypedDocumentNode<
{ changePassword: boolean },
{ currentPassword: string; newPassword: string }
> = gql`
mutation ChangePassword($currentPassword: String!, $newPassword: String!) {
changePassword(currentPassword: $currentPassword, newPassword: $newPassword)
}
`;
18 changes: 11 additions & 7 deletions client/src/routes/ProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { Button } from '../components/ui/Button';
import { PreferencesForm } from '../components/profile/PreferencesForm';
import { EditProfileDialog } from '../components/profile/EditProfileDialog';
import { useCurrentUser } from '../lib/useCurrentUser';
import { useQuery } from '@apollo/client/react';
import { MY_RECIPES } from '../graphql/profile';
Expand Down Expand Up @@ -118,13 +119,16 @@ export default function ProfilePage() {
</ul>

<div className="mt-6 flex gap-3">
<button
type="button"
onClick={() => setActiveTab('preferences')}
className={`flex-1 cursor-pointer rounded-full bg-background px-5 py-3 text-sm font-medium text-text transition-colors hover:bg-peach md:flex-none ${FOCUS_RING}`}
>
Rediger profil
</button>
<EditProfileDialog
trigger={
<button
type="button"
className={`flex-1 cursor-pointer rounded-full bg-background px-5 py-3 text-sm font-medium text-text transition-colors hover:bg-peach md:flex-none ${FOCUS_RING}`}
>
Rediger profil
</button>
}
/>
<button
type="button"
onClick={handleLogout}
Expand Down
69 changes: 69 additions & 0 deletions server/src/schema/resolvers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,75 @@ export const userResolvers = {
await prisma.favorite.create({ data: key });
return true;
},
// endrer displaysnavnet men ikke brukernavnet
updateProfile: async (
_: unknown,
args: { name: string },
context: Context,
) => {
if (!context.userId) {
throw new GraphQLError('Du må være logget inn.', {
extensions: { code: 'UNAUTHENTICATED' },
});
}

// fjerner mellomrom før og etter navnet
const name = args.name.trim();

if (name.length === 0 || name.length > 50) {
throw new GraphQLError('Navnet må være mellom 1 og 50 tegn.', {
extensions: { code: 'BAD_USER_INPUT' },
});
}

return prisma.user.update({
where: { id: context.userId },
data: { name },
});
},
// bytter passord hvis det gamle passordet stemmer
changePassword: async (
_: unknown,
args: { currentPassword: string; newPassword: string },
context: Context,
) => {
if (!context.userId) {
throw new GraphQLError('Du må være logget inn.', {
extensions: { code: 'UNAUTHENTICATED' },
});
}

const user = await prisma.user.findUnique({
where: { id: context.userId },
});

// gjester har ikke passord
const passwordIsCorrect =
user?.passwordHash != null &&
(await bcrypt.compare(args.currentPassword, user.passwordHash));

if (!passwordIsCorrect) {
throw new GraphQLError('Nåværende passord er feil.', {
extensions: { code: 'BAD_USER_INPUT' },
});
}

if (args.newPassword.length < MIN_PASSWORD_LENGTH) {
throw new GraphQLError(
`Nytt passord må ha minst ${MIN_PASSWORD_LENGTH} tegn.`,
{ extensions: { code: 'BAD_USER_INPUT' } },
);
}

// lagrer bare hashen av det nye passordet
const passwordHash = await bcrypt.hash(args.newPassword, 10);
await prisma.user.update({
where: { id: context.userId },
data: { passwordHash },
});

return true;
},
},

User: {
Expand Down
2 changes: 2 additions & 0 deletions server/src/schema/typeDefs/user.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ extend type Mutation {
unitSystem: String!
allergens: [String!]!
): User!
updateProfile(name: String!): User!
changePassword(currentPassword: String!, newPassword: String!): Boolean!
# legger til eller fjerner en favoritt
toggleFavorite(recipeId: ID!): Boolean!
}