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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ vite.config.ts.timestamp-*
# Other package managers
package-lock.json
yarn.lock

.claude
119 changes: 119 additions & 0 deletions src/lib/components/auth/login-form.svelte
Copy link
Contributor

Choose a reason for hiding this comment

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

Could this be moved back to page if its not going to be re-used somewhere else?

Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import {
FieldGroup,
Field,
FieldDescription,
FieldSeparator,
} from '$lib/components/ui/field/index.js';
import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements';
import TextInput from '../input/TextInput.svelte';
import type { ValidationResult } from '$lib/types/ValidationResult';
import PasswordInput from '../input/PasswordInput.svelte';
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
import Turnstile from '$lib/components/Turnstile.svelte';
import { resolve } from '$app/paths';
import { accountV2Api } from '$lib/api';
import { UserStore } from '$lib/stores/UserStore';
import { initializeSignalR } from '$lib/signalr';
import { goto } from '$app/navigation';
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
import { isValidationError, mapToValRes } from '$lib/errorhandling/ValidationProblemDetails';
import { page } from '$app/state';
import OauthButtons from './oauth-buttons.svelte';

let usernameOrEmail = $state('');
let password = $state('');
let turnstileResponse = $state<string | null>(null);

let usernameError = $state<ValidationResult | null>(null);
let passwordError = $state<ValidationResult | null>(null);

async function handleSubmission(e: SubmitEvent) {
e.preventDefault();

if (!usernameOrEmail || !password || !turnstileResponse) {
return;
}

try {
const account = await accountV2Api.accountLoginV2({
usernameOrEmail,
password,
turnstileResponse,
});
UserStore.setSelf({
id: account.accountId,
name: account.accountName,
avatar: account.profileImage,
email: account.accountEmail,
roles: account.accountRoles,
});
await initializeSignalR();
goto(page.url.searchParams.get('redirect') ?? '/home');
} catch (error) {
await handleApiError(error, (problem) => {
if (!isValidationError(problem)) return false;
usernameError = mapToValRes(problem, 'UsernameOrEmail');
passwordError = mapToValRes(problem, 'Password');
return true;
});
}
}

let canSubmit = $derived(
usernameOrEmail.length > 0 && password.length > 0 && turnstileResponse != null
);
</script>

<div class={cn('flex max-w-sm flex-col gap-6', className)} {...restProps}>
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">Welcome back</Card.Title>
<Card.Description>Login with your Discord or Twitter account</Card.Description>
</Card.Header>
<Card.Content>
<FieldGroup>
<OauthButtons />
<FieldSeparator class="*:data-[slot=field-separator-content]:bg-card">
Or continue with
</FieldSeparator>
<form onsubmit={handleSubmission}>
<div>
<div class="my-1 flex flex-col gap-1">
<TextInput
label="Username or Email"
autocomplete="username"
bind:value={usernameOrEmail}
validationResult={usernameError}
/>

<PasswordInput
label="Password"
autocomplete="current-password"
bind:value={password}
validate={passwordError}
/>

<Turnstile action="signin" bind:response={turnstileResponse} />
</div>
<Field class="mt-5">
<Button type="submit" disabled={!canSubmit}>Login</Button>
<FieldDescription class="text-center">
Don't have an account? <a href={resolve('/signup')}>Sign up</a>
</FieldDescription>
</Field>
</div>
</form>
</FieldGroup>
</Card.Content>
</Card.Root>
<FieldDescription class="px-6 text-center">
By clicking continue, you agree to our <a href="https://openshock.org/tos" target="_blank"
>Terms of Service</a
>
and <a href="https://openshock.org/privacy" target="_blank">Privacy Policy</a>.
</FieldDescription>
</div>
32 changes: 32 additions & 0 deletions src/lib/components/auth/oauth-buttons.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Field } from '$lib/components/ui/field/index.js';
import { GetOAuthAuthorizeUrl } from '$lib/api/next/oauth';
import XLogo from '../svg/XLogo.svelte';
import DiscordLogo from '../svg/DiscordLogo.svelte';
import GoogleLogo from '../svg/GoogleLogo.svelte';
import { LogIn } from '@lucide/svelte';
import { backendMetadata } from '$lib/state/BackendMetadata.svelte';

const providerDetails: Record<string, { icon: typeof XLogo; label: string }> = {
discord: { icon: DiscordLogo, label: 'Discord' },
twitter: { icon: XLogo, label: 'X (Twitter)' },
google: { icon: GoogleLogo, label: 'Google' },
};
</script>

<Field>
{#each backendMetadata.State.oAuthProviders as provider (provider)}
{@const detail = providerDetails[provider]}
<form action={GetOAuthAuthorizeUrl(provider, 'LoginOrCreate')} method="POST">
<Button variant="outline" type="submit" class="w-full">
{#if detail}
<detail.icon></detail.icon>
{:else}
<LogIn />
{/if}
Login with {detail?.label ?? provider}
</Button>
</form>
{/each}
</Field>
162 changes: 162 additions & 0 deletions src/lib/components/auth/signup-form.svelte
Copy link
Contributor

Choose a reason for hiding this comment

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

Could this be moved back to page if its not going to be re-used somewhere else?

Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<script lang="ts">
import { cn } from '$lib/utils';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import type { HTMLAttributes } from 'svelte/elements';
import UsernameInput from '../input/UsernameInput.svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { accountV2Api } from '$lib/api';
import Turnstile from '$lib/components/Turnstile.svelte';
import EmailInput from '$lib/components/input/EmailInput.svelte';
import PasswordInput from '$lib/components/input/PasswordInput.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { isValidationError, mapToValRes } from '$lib/errorhandling/ValidationProblemDetails';
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
import { validatePasswordMatch } from '$lib/inputvalidation/passwordValidator';
import { toast } from 'svelte-sonner';
import FieldSeparator from '../ui/field/field-separator.svelte';
import OauthButtons from './oauth-buttons.svelte';

let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();

let username = $state<string>('');
let usernameValid = $state<boolean>(false);

let email = $state('');
let emailValid = $state(false);

let password = $state('');
let passwordValid = $state(false);

let passwordConfirm = $state('');

let turnstileResponse = $state<string | null>(null);

let canSubmit = $derived(
usernameValid && emailValid && passwordValid && password == passwordConfirm && turnstileResponse
);

let accountCreated = $state(false);

function onOpenChange(open: boolean) {
if (!open) {
accountCreated = false;
toast.success(
'Account created successfully. Please check your email to verify your account.'
);
goto(resolve('/login'));
}
}

async function handleSubmission(e: SubmitEvent) {
e.preventDefault();

if (!username || !email || !password || !passwordConfirm || !turnstileResponse) {
return;
}

try {
await accountV2Api.accountSignUpV2({
username,
password,
email,
turnstileResponse,
});
accountCreated = true;
} catch (error) {
await handleApiError(error, (problem) => {
if (!isValidationError(problem)) return false;

console.log(mapToValRes(problem, 'Username'));
console.log(mapToValRes(problem, 'Password'));
console.log(mapToValRes(problem, 'Email'));
console.log(mapToValRes(problem, 'TurnstileResponse'));

return true;
});
}
}
</script>

<Dialog.Root bind:open={() => accountCreated, onOpenChange}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Welcome! Thank you for signing up! ❤️</Dialog.Title>
<Dialog.Description>
<div class="flex flex-col gap-4">
<p>Your account has been created. 🎉 Please check your email to verify your account.</p>
<p>After verifying your email, you can log in to your account.</p>

<Button variant="default" size="sm" class="mt-4" onclick={() => goto(resolve('/login'))}
>Ok</Button
>
</div>
</Dialog.Description>
</Dialog.Header>
</Dialog.Content>
</Dialog.Root>

<div class={cn('flex max-w-sm flex-col gap-6', className)} {...restProps}>
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">Create your account</Card.Title>
<Card.Description>Enter your email below to create your account</Card.Description>
</Card.Header>
<Card.Content>
<Field.Group>
<OauthButtons />
<FieldSeparator class="*:data-[slot=field-separator-content]:bg-card">
Or continue with
</FieldSeparator>

<form onsubmit={handleSubmission}>
<div class="my-1 flex flex-col gap-1">
<UsernameInput
label="Username"
placeholder="John OpenShock"
bind:value={username}
bind:valid={usernameValid}
/>
<EmailInput
label="Email"
placeholder="[email protected]"
bind:value={email}
bind:valid={emailValid}
/>
<PasswordInput
label="Password"
placeholder="Password"
autocomplete="new-password"
bind:value={password}
bind:valid={passwordValid}
validate
showStrengthMeter
showForget={false}
/>
<PasswordInput
label="Confirm Password"
placeholder="Confirm Password"
autocomplete="new-password"
bind:value={passwordConfirm}
validate={validatePasswordMatch(passwordConfirm, password)}
showForget={false}
/>
<Turnstile action="signup" bind:response={turnstileResponse} />
</div>
<Field.Field class="mt-5">
<Button type="submit" disabled={!canSubmit}>Create Account</Button>
<Field.Description class="text-center">
Already have an account? <a href={resolve('/login')}>Sign in</a>
</Field.Description>
</Field.Field>
</form>
</Field.Group>
</Card.Content>
</Card.Root>
<Field.Description class="px-6 text-center">
By clicking continue, you agree to our <a href="https://openshock.org/tos">Terms of Service</a>
and <a href="https://openshock.org/privacy">Privacy Policy</a>.
</Field.Description>
</div>
22 changes: 19 additions & 3 deletions src/lib/components/input/PasswordInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import type { Snippet } from 'svelte';
import type { FullAutoFill } from 'svelte/elements';
import PasswordStrengthMeter from './impl/PasswordStrengthMeter.svelte';
import { FieldLabel } from '../ui/field';
import { resolve } from '$app/paths';

interface Props {
label: string;
Expand All @@ -22,6 +24,7 @@
validate?: boolean | 'string' | 'pwned' | ValidationResult | null;
showStrengthMeter?: boolean;
Icon?: AnyComponent;
showForget?: boolean;
}

let {
Expand All @@ -34,6 +37,7 @@
validate = false,
showStrengthMeter = false,
Icon,
showForget = true,
}: Props = $props();

let validationResult = $state<ValidationResult | null>(null);
Expand Down Expand Up @@ -127,7 +131,6 @@

<TextInput
type={valueShown ? 'text' : 'password'}
{label}
{placeholder}
{autocomplete}
bind:value
Expand All @@ -136,13 +139,26 @@
onblur={() => (showPopup = false)}
popup={showPopup ? (popup as Snippet) : undefined}
>
{#snippet labelSnippet(id: string)}
<div class="flex items-center">
<FieldLabel for={id}>{label}</FieldLabel>
{#if showForget}
<a
href={resolve('/forgot-password')}
class="ms-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
{/if}
</div>
{/snippet}
{#snippet after()}
<Button
type="button"
class="cursor-pointer"
class="h-7 w-7 cursor-pointer"
onclick={() => (valueShown = !valueShown)}
variant="ghost"
disabled={value.length == 0}
size="icon"
>
{#if valueShown}
<EyeOff />
Expand Down
Loading