diff --git a/bun.lockb b/bun.lockb
index 598e05a5..03621b42 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/messages/en.json b/messages/en.json
index ba8261a8..003813a2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -21,7 +21,9 @@
"selectMonth": "Select month",
"selectYear": "Select year",
"pickDate": "Pick a date",
- "dateFormat": "dd/MM/yyyy"
+ "dateFormat": "dd/MM/yyyy",
+ "hidePassword": "Hide password",
+ "showPassword": "Show password"
},
"error": {
"notFound": "404 - Page not found",
@@ -37,7 +39,31 @@
"signInWith": "Sign in with",
"hackerspaceAccount": "Hackerspace Account",
"success": "Success!",
- "home": "Home"
+ "home": "Home",
+ "signIn": "Sign in",
+ "useYourAccount": "Use your account",
+ "forgotPassword": "Forgot password?",
+ "submit": "Submit",
+ "form": {
+ "username": {
+ "label": "Username",
+ "required": "Username is required",
+ "minLength": "Username must be at least 5 characters",
+ "maxLength": "Username must be less than 8 characters",
+ "invalid": "Username must contain only letters"
+ },
+ "password": {
+ "label": "Password",
+ "required": "Password is required",
+ "minLength": "Password must be at least 8 characters",
+ "maxLength": "Password must be less than 50 characters",
+ "uppercase": "Password must contain at least one uppercase letter",
+ "specialChar": "Password must contain at least one special character",
+ "confirmLabel": "Confirm password",
+ "mismatch": "Passwords do not match",
+ "weak": "Password is too weak"
+ }
+ }
},
"layout": {
"hackerspaceHome": "Hackerspace homepage",
diff --git a/messages/no.json b/messages/no.json
index f52f7ba2..b3592f51 100644
--- a/messages/no.json
+++ b/messages/no.json
@@ -21,7 +21,9 @@
"selectMonth": "Velg måned",
"selectYear": "Velg år",
"pickDate": "Velg en dato",
- "dateFormat": "dd.MM.yyyy"
+ "dateFormat": "dd.MM.yyyy",
+ "hidePassword": "Gjem passord",
+ "showPassword": "Vis passord"
},
"error": {
"notFound": "404 - Siden ble ikke funnet",
@@ -37,7 +39,31 @@
"signInWith": "Logg inn med",
"hackerspaceAccount": "Hackerspace-konto",
"success": "Suksess!",
- "home": "Hjem"
+ "home": "Hjem",
+ "signIn": "Logg inn",
+ "useYourAccount": "Bruk din konto",
+ "forgotPassword": "Glemt passord?",
+ "submit": "Send",
+ "form": {
+ "username": {
+ "label": "Brukernavn",
+ "required": "Brukernavn er påkrevd",
+ "minLength": "Brukernavn må være minst 5 tegn",
+ "maxLength": "Brukernavn må være mindre enn 8 tegn",
+ "invalid": "Brukernavn må kun inneholde bokstaver"
+ },
+ "password": {
+ "label": "Passord",
+ "required": "Passord er påkrevd",
+ "minLength": "Passord må være minst 8 tegn",
+ "maxLength": "Passord må være mindre enn 50 tegn",
+ "uppercase": "Passord må inneholde minst én stor bokstav",
+ "specialChar": "Passord må inneholde minst ett spesialtegn",
+ "confirmLabel": "Bekreft passord",
+ "mismatch": "Passordene stemmer ikke overens",
+ "weak": "Passordet er for svakt"
+ }
+ }
},
"layout": {
"hackerspaceHome": "Hackerspace hjemmeside",
diff --git a/src/app/[locale]/auth/account/page.tsx b/src/app/[locale]/auth/account/page.tsx
new file mode 100644
index 00000000..30725ec8
--- /dev/null
+++ b/src/app/[locale]/auth/account/page.tsx
@@ -0,0 +1,12 @@
+import { AccountSignInForm } from '@/components/auth/AccountSignInForm';
+import { setRequestLocale } from 'next-intl/server';
+
+export default async function AccountPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ setRequestLocale(locale);
+ return ;
+}
diff --git a/src/app/[locale]/auth/create-account/page.tsx b/src/app/[locale]/auth/create-account/page.tsx
new file mode 100644
index 00000000..dbe8e683
--- /dev/null
+++ b/src/app/[locale]/auth/create-account/page.tsx
@@ -0,0 +1,32 @@
+import { Button } from '@/components/ui/Button';
+import { Separator } from '@/components/ui/Separator';
+import { Link } from '@/lib/locale/navigation';
+import { getTranslations, setRequestLocale } from 'next-intl/server';
+
+export default async function CreateAccountPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ setRequestLocale(locale);
+ const t = await getTranslations('auth');
+ return (
+
+
+
{t('success')}
+
+ {
+ 'you are now a member of Hackerspace. Now you can finally start praying to our one true leader'
+ }
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/auth/page.tsx b/src/app/[locale]/auth/page.tsx
index 948b62e5..e64c7e31 100644
--- a/src/app/[locale]/auth/page.tsx
+++ b/src/app/[locale]/auth/page.tsx
@@ -1,6 +1,7 @@
import { FeideLogo } from '@/components/assets/logos/FeideLogo';
import { Button } from '@/components/ui/Button';
import { Separator } from '@/components/ui/Separator';
+import { Link } from '@/lib/locale/navigation';
import { FingerprintIcon } from 'lucide-react';
import { getTranslations, setRequestLocale } from 'next-intl/server';
@@ -21,12 +22,17 @@ export default async function SignInPage({
{t('signInWith')}
-
diff --git a/src/components/auth/AccountSignInForm.tsx b/src/components/auth/AccountSignInForm.tsx
new file mode 100644
index 00000000..085df6e8
--- /dev/null
+++ b/src/components/auth/AccountSignInForm.tsx
@@ -0,0 +1,111 @@
+'use client';
+
+import { accountSignInSchema } from '@/validations/auth/accountSignInSchema';
+import { useTranslations } from 'next-intl';
+import { useState } from 'react';
+
+import { api } from '@/lib/api/client';
+import { Link } from '@/lib/locale/navigation';
+import { useRouter } from '@/lib/locale/navigation';
+
+import { usePending } from '@/components/auth/PendingBar';
+import { PasswordInput } from '@/components/composites/PasswordInput';
+import { Button } from '@/components/ui/Button';
+import {
+ Form,
+ FormControl,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ useForm,
+} from '@/components/ui/Form';
+import { Input } from '@/components/ui/Input';
+
+function AccountSignInForm() {
+ const router = useRouter();
+ const t = useTranslations('auth');
+ const [accountCreated, setAccountCreated] = useState(false);
+ const formSchema = accountSignInSchema(t as (key: string) => string, false);
+ const { isPending, setPending } = usePending();
+
+ const form = useForm(formSchema, {
+ defaultValues: {
+ username: '',
+ password: '',
+ },
+ onSubmit: () => {
+ if (!accountCreated) {
+ router.push('/auth/create-account');
+ }
+ router.push('/');
+ },
+ });
+
+ return (
+
+
+
{t('signIn')}
+
{t('useYourAccount')}
+
+
+ {(field) => (
+
+ {t('form.username.label')}
+
+ field.handleChange(e.target.value)}
+ onBlur={field.handleBlur}
+ />
+
+
+
+ )}
+
+
+ {(field) => (
+
+
+ {t('form.password.label')}
+
+
+ {`${t('forgotPassword')}?`}
+
+
+
+
+ field.handleChange(e.target.value)}
+ onBlur={field.handleBlur}
+ />
+
+
+
+ )}
+
+
+
[state.canSubmit]}>
+ {([canSubmit]) => (
+
+ {t('submit')}
+
+ )}
+
+
+
+
+ );
+}
+
+export { AccountSignInForm };
diff --git a/src/components/composites/PasswordInput.tsx b/src/components/composites/PasswordInput.tsx
new file mode 100644
index 00000000..432a3ba2
--- /dev/null
+++ b/src/components/composites/PasswordInput.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { EyeIcon, EyeOffIcon } from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import * as React from 'react';
+
+import { cx } from '@/lib/utils';
+
+import { Button } from '@/components/ui/Button';
+import { Input, type InputProps } from '@/components/ui/Input';
+
+const PasswordInput = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ const t = useTranslations('ui');
+ const [showPassword, setShowPassword] = React.useState(false);
+ const disabled =
+ props.value === '' || props.value === undefined || props.disabled;
+
+ return (
+
+
+ setShowPassword((prev) => !prev)}
+ disabled={disabled}
+ aria-label={showPassword ? t('showPassword') : t('hidePassword')}
+ >
+ {showPassword && !disabled ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ },
+);
+PasswordInput.displayName = 'PasswordInput';
+
+export { PasswordInput };
diff --git a/src/components/layout/header/ProfileMenu.tsx b/src/components/layout/header/ProfileMenu.tsx
index 13c9b860..9504a666 100644
--- a/src/components/layout/header/ProfileMenu.tsx
+++ b/src/components/layout/header/ProfileMenu.tsx
@@ -7,6 +7,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/DropdownMenu';
+import { Link } from '@/lib/locale/navigation';
import { UserIcon } from 'lucide-react';
import * as React from 'react';
@@ -20,7 +21,9 @@ function ProfileMenu({ t }: { t: { profile: string; signIn: string } }) {
- {t.signIn}
+
+ {t.signIn}
+
);
diff --git a/src/lib/locale/index.ts b/src/lib/locale/index.ts
index 17bc19a6..ce6b211f 100644
--- a/src/lib/locale/index.ts
+++ b/src/lib/locale/index.ts
@@ -22,6 +22,10 @@ const routing = defineRouting({
en: '/auth',
no: '/autentisering',
},
+ '/auth/account': {
+ en: '/auth/account',
+ no: '/autentisering/konto',
+ },
'/auth/create-account': {
en: '/auth/create-account',
no: '/autentisering/opprett-konto',
diff --git a/src/validations/auth/accountSignInSchema.ts b/src/validations/auth/accountSignInSchema.ts
new file mode 100644
index 00000000..d55ac683
--- /dev/null
+++ b/src/validations/auth/accountSignInSchema.ts
@@ -0,0 +1,25 @@
+import { z } from 'zod';
+
+function accountSignInSchema(t: (key: string) => string, isStrict = true) {
+ let passwordSchema = z.string().min(1, t('form.password.required'));
+
+ if (isStrict) {
+ passwordSchema = passwordSchema
+ .min(8, t('form.password.minLength'))
+ .max(50, t('form.password.maxLength'))
+ .regex(/[A-Z]/, t('form.password.uppercase'))
+ .regex(/[^a-zA-Z0-9]/, t('form.password.specialChar'));
+ }
+
+ return z.object({
+ username: z
+ .string()
+ .min(1, t('form.username.required'))
+ .min(5, t('form.username.minLength'))
+ .max(8, t('form.username.maxLength'))
+ .regex(/^[a-z]+$/, t('form.username.invalid')),
+ password: passwordSchema,
+ });
+}
+
+export { accountSignInSchema };