Compare commits

...

4 commits

Author SHA1 Message Date
sam
9d33093339
feat: forgot password/reset password 2024-12-14 16:32:08 +01:00
sam
26b32b40e2
feat: show utc offset on profile 2024-12-14 14:00:48 +01:00
sam
5cdadc6158
fix: remove scoped styles from user pages
these are *hell* for user styles and they're really not necessary.
they are still used on some editor pages as those are less important
to be able to comprehensively style, imo
2024-12-14 00:52:44 +01:00
sam
39a3098a99
fix: fix all eslint errors 2024-12-14 00:46:27 +01:00
41 changed files with 834 additions and 456 deletions

View file

@ -183,6 +183,63 @@ public class EmailAuthController(
return NoContent(); return NoContent();
} }
[HttpPost("forgot-password")]
public async Task<IActionResult> ForgotPasswordAsync([FromBody] EmailForgotPasswordRequest req)
{
CheckRequirements();
if (!req.Email.Contains('@'))
throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
AuthMethod? authMethod = await db
.AuthMethods.Where(m => m.AuthType == AuthType.Email && m.RemoteId == req.Email)
.FirstOrDefaultAsync();
if (authMethod == null)
return NoContent();
string state = await keyCacheService.GenerateForgotPasswordStateAsync(
req.Email,
authMethod.UserId
);
if (IsRateLimited())
return NoContent();
mailService.QueueResetPasswordEmail(req.Email, state);
return NoContent();
}
[HttpPost("reset-password")]
public async Task<IActionResult> ResetPasswordAsync([FromBody] EmailResetPasswordRequest req)
{
ForgotPasswordState? state = await keyCacheService.GetForgotPasswordStateAsync(req.State);
if (state == null)
throw new ApiError.BadRequest("Unknown state", "state", req.State);
if (
!await db
.AuthMethods.Where(m =>
m.AuthType == AuthType.Email
&& m.RemoteId == state.Email
&& m.UserId == state.UserId
)
.AnyAsync()
)
{
throw new ApiError.BadRequest("Invalid state");
}
ValidationUtils.Validate([("password", ValidationUtils.ValidatePassword(req.Password))]);
User user = await db.Users.FirstAsync(u => u.Id == state.UserId);
await authService.SetUserPasswordAsync(user, req.Password);
await db.SaveChangesAsync();
mailService.QueuePasswordChangedEmail(state.Email);
return NoContent();
}
[HttpPost("add-account")] [HttpPost("add-account")]
[Authorize("*")] [Authorize("*")]
public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req) public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req)

View file

@ -59,4 +59,8 @@ public record EmailCallbackRequest(string State);
public record EmailChangePasswordRequest(string Current, string New); public record EmailChangePasswordRequest(string Current, string New);
public record EmailForgotPasswordRequest(string Email);
public record EmailResetPasswordRequest(string State, string Password);
public record FediverseCallbackRequest(string Instance, string Code, string? State = null); public record FediverseCallbackRequest(string Instance, string Code, string? State = null);

View file

@ -28,7 +28,7 @@ public static class KeyCacheExtensions
CancellationToken ct = default CancellationToken ct = default
) )
{ {
string state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); string state = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct);
return state; return state;
} }
@ -51,8 +51,7 @@ public static class KeyCacheExtensions
CancellationToken ct = default CancellationToken ct = default
) )
{ {
// This state is used in links, not just as JSON values, so make it URL-safe string state = AuthUtils.RandomToken();
string state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
await keyCacheService.SetKeyAsync( await keyCacheService.SetKeyAsync(
$"email_state:{state}", $"email_state:{state}",
new RegisterEmailState(email, userId), new RegisterEmailState(email, userId),
@ -112,11 +111,12 @@ public static class KeyCacheExtensions
public static async Task<ForgotPasswordState?> GetForgotPasswordStateAsync( public static async Task<ForgotPasswordState?> GetForgotPasswordStateAsync(
this KeyCacheService keyCacheService, this KeyCacheService keyCacheService,
string state, string state,
bool delete = true,
CancellationToken ct = default CancellationToken ct = default
) => ) =>
await keyCacheService.GetKeyAsync<ForgotPasswordState>( await keyCacheService.GetKeyAsync<ForgotPasswordState>(
$"forgot_password:{state}", $"forgot_password:{state}",
true, delete,
ct ct
); );
} }

View file

@ -102,7 +102,7 @@ public class CreateDataExportInvocable(
stream.Seek(0, SeekOrigin.Begin); stream.Seek(0, SeekOrigin.Begin);
// Upload the file! // Upload the file!
string filename = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); string filename = AuthUtils.RandomToken();
await objectStorageService.PutObjectAsync( await objectStorageService.PutObjectAsync(
ExportPath(user.Id, filename), ExportPath(user.Id, filename),
stream, stream,

View file

@ -0,0 +1,25 @@
using Coravel.Mailer.Mail;
namespace Foxnouns.Backend.Mailables;
public class PasswordChangedMailable(Config config, PasswordChangedMailableView view)
: Mailable<PasswordChangedMailableView>
{
private string PlainText() =>
$"""
Your password has been changed using a "forgot password" link.
If this wasn't you, request a password reset immediately:
{view.BaseUrl}/auth/forgot-password
""";
public override void Build()
{
To(view.To)
.From(config.EmailAuth.From!)
.Subject("Your password has been changed")
.View("~/Views/Mail/PasswordChanged.cshtml", view)
.Text(PlainText());
}
}
public class PasswordChangedMailableView : BaseView;

View file

@ -0,0 +1,32 @@
using Coravel.Mailer.Mail;
namespace Foxnouns.Backend.Mailables;
public class ResetPasswordMailable(Config config, ResetPasswordMailableView view)
: Mailable<ResetPasswordMailableView>
{
private string PlainText() =>
$"""
Somebody (hopefully you!) has requested a password reset.
You can use the following link to do this:
{view.BaseUrl}/auth/forgot-password/{view.Code}
Note that this link will expire in one hour.
If you weren't expecting this email, you don't have to do anything.
Your password can't be changed without the above link.
""";
public override void Build()
{
To(view.To)
.From(config.EmailAuth.From!)
.Subject("Reset your account's password")
.View("~/Views/Mail/ResetPassword.cshtml", view)
.Text(PlainText());
}
}
public class ResetPasswordMailableView : BaseView
{
public required string Code { get; init; }
}

View file

@ -63,6 +63,41 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
}); });
} }
public void QueueResetPasswordEmail(string to, string code)
{
_logger.Debug("Sending add email address email to {ToEmail}", to);
queue.QueueAsyncTask(async () =>
{
await SendEmailAsync(
to,
new ResetPasswordMailable(
config,
new ResetPasswordMailableView
{
BaseUrl = config.BaseUrl,
To = to,
Code = code,
}
)
);
});
}
public void QueuePasswordChangedEmail(string to)
{
_logger.Debug("Sending add email address email to {ToEmail}", to);
queue.QueueAsyncTask(async () =>
{
await SendEmailAsync(
to,
new PasswordChangedMailable(
config,
new PasswordChangedMailableView { BaseUrl = config.BaseUrl, To = to }
)
);
});
}
private async Task SendEmailAsync<T>(string to, Mailable<T> mailable) private async Task SendEmailAsync<T>(string to, Mailable<T> mailable)
{ {
try try

View file

@ -131,7 +131,12 @@ public static class AuthUtils
} }
public static string RandomToken(int bytes = 48) => public static string RandomToken(int bytes = 48) =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('='); Convert
.ToBase64String(RandomNumberGenerator.GetBytes(bytes))
.Trim('=')
// Make the token URL-safe
.Replace('+', '-')
.Replace('/', '_');
public const int MaxAuthMethodsPerType = 3; // Maximum of 3 Discord accounts, 3 emails, etc public const int MaxAuthMethodsPerType = 3; // Maximum of 3 Discord accounts, 3 emails, etc
} }

View file

@ -0,0 +1,8 @@
@model Foxnouns.Backend.Mailables.PasswordChangedMailableView
<p>
Your password has been changed using a "forgot password" link.
If this wasn't you, please a password reset immediately:
<br />
<a href="@Model.BaseUrl/auth/forgot-password">@Model.BaseUrl/auth/forgot-password</a>
</p>

View file

@ -0,0 +1,14 @@
@model Foxnouns.Backend.Mailables.ResetPasswordMailableView
<p>
Somebody (hopefully you!) has requested a password reset.
You can use the following link to do this:
<br />
<a href="@Model.BaseUrl/auth/forgot-password/@Model.Code">@Model.BaseUrl/auth/forgot-password/@Model.Code</a>
<br />
Note that this link will expire in one hour.
</p>
<p>
If you weren't expecting this email, you don't have to do anything.
Your password can't be changed without the above link.
</p>

View file

@ -30,4 +30,16 @@ export default ts.config(
{ {
ignores: ["build/", ".svelte-kit/", "dist/"], ignores: ["build/", ".svelte-kit/", "dist/"],
}, },
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
); );

View file

@ -12,29 +12,29 @@
"lint": "prettier --check . && eslint ." "lint": "prettier --check . && eslint ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.2.9", "@sveltejs/adapter-node": "^5.2.10",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.11.1",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.3",
"@sveltestrap/sveltestrap": "^6.2.7", "@sveltestrap/sveltestrap": "^6.2.7",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.13.0", "@types/sanitize-html": "^2.13.0",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"eslint": "^9.7.0", "eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0", "eslint-plugin-svelte": "^2.46.1",
"globals": "^15.0.0", "globals": "^15.13.0",
"prettier": "^3.3.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.3.2",
"sass": "^1.81.0", "sass": "^1.83.0",
"svelte": "^5.0.0", "svelte": "^5.12.0",
"svelte-bootstrap-icons": "^3.1.1", "svelte-bootstrap-icons": "^3.1.1",
"svelte-check": "^4.0.0", "svelte-check": "^4.1.1",
"sveltekit-i18n": "^2.4.2", "sveltekit-i18n": "^2.4.2",
"typescript": "^5.0.0", "typescript": "^5.7.2",
"typescript-eslint": "^8.0.0", "typescript-eslint": "^8.18.0",
"vite": "^5.0.3" "vite": "^5.4.11"
}, },
"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee", "packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee",
"dependencies": { "dependencies": {

File diff suppressed because it is too large Load diff

View file

@ -49,3 +49,18 @@
} }
} }
} }
// Give identicons a background distinguishable from the page
.identicon {
@media (prefers-color-scheme: dark) {
background-color: var(--bs-secondary-border-subtle);
}
background-color: var(--bs-light-border-subtle);
}
.profile-flag {
height: 1.5rem;
max-width: 200px;
border-radius: 3px;
}

View file

@ -10,7 +10,7 @@ export default function createCallbackLoader(
bodyFn?: (event: ServerLoadEvent) => Promise<unknown>, bodyFn?: (event: ServerLoadEvent) => Promise<unknown>,
) { ) {
return async (event: ServerLoadEvent) => { return async (event: ServerLoadEvent) => {
const { url, parent, fetch, cookies } = event; const { parent, fetch, cookies } = event;
bodyFn ??= async ({ url }) => { bodyFn ??= async ({ url }) => {
const code = url.searchParams.get("code") as string | null; const code = url.searchParams.get("code") as string | null;

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export default class ApiError { export default class ApiError {
raw?: RawApiError; raw?: RawApiError;
code: ErrorCode; code: ErrorCode;

View file

@ -23,7 +23,7 @@ export type RequestArgs = {
/** /**
* The body for this request, which will be serialized to JSON. Should be a plain JS object. * The body for this request, which will be serialized to JSON. Should be a plain JS object.
*/ */
body?: any; body?: unknown;
/** /**
* The fetch function to use. Should be passed in loader and action functions, but can be safely ignored for client-side requests. * The fetch function to use. Should be passed in loader and action functions, but can be safely ignored for client-side requests.
*/ */

View file

@ -26,12 +26,4 @@
img { img {
object-fit: cover; object-fit: cover;
} }
.identicon {
@media (prefers-color-scheme: dark) {
background-color: var(--bs-secondary-border-subtle);
}
background-color: var(--bs-light-border-subtle);
}
</style> </style>

View file

@ -21,6 +21,8 @@
{#if value !== ""} {#if value !== ""}
<div class="card"> <div class="card">
<div class="card-header">{$t("edit-profile.preview")}</div> <div class="card-header">{$t("edit-profile.preview")}</div>
<!-- bios are sanitized before being passed to @html and the allowed markdown is heavily restricted -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="card-body">{@html renderMarkdown(value)}</div> <div class="card-body">{@html renderMarkdown(value)}</div>
</div> </div>
{/if} {/if}

View file

@ -10,17 +10,9 @@
<span class="mx-2 my-1"> <span class="mx-2 my-1">
<img <img
use:tippy={{ content: flag.description ?? flag.name }} use:tippy={{ content: flag.description ?? flag.name }}
class="flag" class="profile-flag"
src={flag.image_url ?? DEFAULT_FLAG} src={flag.image_url ?? DEFAULT_FLAG}
alt={flag.description ?? flag.name} alt={flag.description ?? flag.name}
/> />
{flag.name} {flag.name}
</span> </span>
<style>
.flag {
height: 1.5rem;
max-width: 200px;
border-radius: 3px;
}
</style>

View file

@ -5,14 +5,16 @@
import ProfileLink from "./ProfileLink.svelte"; import ProfileLink from "./ProfileLink.svelte";
import ProfileFlag from "./ProfileFlag.svelte"; import ProfileFlag from "./ProfileFlag.svelte";
import Avatar from "$components/Avatar.svelte"; import Avatar from "$components/Avatar.svelte";
import TimeOffset from "./TimeOffset.svelte";
type Props = { type Props = {
name: string; name: string;
profile: User | Member; profile: User | Member;
lazyLoadAvatar?: boolean; lazyLoadAvatar?: boolean;
offset?: number | null;
}; };
let { name, profile, lazyLoadAvatar }: Props = $props(); let { name, profile, lazyLoadAvatar, offset }: Props = $props();
// renderMarkdown sanitizes the output HTML for us // renderMarkdown sanitizes the output HTML for us
let bio = $derived(renderMarkdown(profile.bio)); let bio = $derived(renderMarkdown(profile.bio));
@ -45,8 +47,11 @@
{:else} {:else}
<h2>{name}</h2> <h2>{name}</h2>
{/if} {/if}
{#if offset}<TimeOffset {offset} />{/if}
{#if bio} {#if bio}
<hr /> <hr />
<!-- bios are sanitized before being passed to @html and the allowed markdown is heavily restricted -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<p>{@html bio}</p> <p>{@html bio}</p>
{/if} {/if}
</div> </div>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { DateTime, FixedOffsetZone } from "luxon";
import Clock from "svelte-bootstrap-icons/lib/Clock.svelte";
type Props = { offset: number };
let { offset }: Props = $props();
let { currentTime, timezone } = $derived.by(() => {
const zone = FixedOffsetZone.instance(offset / 60);
return {
currentTime: DateTime.now().setZone(zone).toLocaleString(DateTime.TIME_SIMPLE),
timezone: zone.formatOffset(DateTime.now().toUnixInteger(), "narrow"),
};
});
</script>
<Clock aria-hidden />
{currentTime} <span class="text-body-secondary">(UTC{timezone})</span>

View file

@ -35,7 +35,7 @@
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<FormStatusMarker {form} /> <FormStatusMarker {form} successMessage={$t("auth.password-changed-hint")} />
<h4>Change password</h4> <h4>Change password</h4>
<form method="POST" action="?/password"> <form method="POST" action="?/password">
<div class="mb-1"> <div class="mb-1">

View file

@ -1,6 +1,7 @@
import { ErrorCode } from "$api/error"; import { ErrorCode } from "$api/error";
import type { Modifier } from "sveltekit-i18n"; import type { Modifier } from "sveltekit-i18n";
// eslint-disable-next-line
type TranslateFn = (key: string, payload?: any, props?: Modifier.Props<{}> | undefined) => any; type TranslateFn = (key: string, payload?: any, props?: Modifier.Props<{}> | undefined) => any;
export default function errorDescription(t: TranslateFn, code: ErrorCode): string { export default function errorDescription(t: TranslateFn, code: ErrorCode): string {

View file

@ -1,6 +1,7 @@
import { PUBLIC_LANGUAGE } from "$env/static/public"; import { PUBLIC_LANGUAGE } from "$env/static/public";
import i18n, { type Config } from "sveltekit-i18n"; import i18n, { type Config } from "sveltekit-i18n";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const config: Config<any> = { const config: Config<any> = {
initLocale: PUBLIC_LANGUAGE, initLocale: PUBLIC_LANGUAGE,
fallbackLocale: "en", fallbackLocale: "en",

View file

@ -61,7 +61,13 @@
"add-email-address": "Add email address", "add-email-address": "Add email address",
"no-email-addresses": "You haven't linked any email addresses yet.", "no-email-addresses": "You haven't linked any email addresses yet.",
"check-inbox-for-link-hint": "Check your inbox for a link!", "check-inbox-for-link-hint": "Check your inbox for a link!",
"successful-link-email": "Your account has successfully been linked to the following email address:" "successful-link-email": "Your account has successfully been linked to the following email address:",
"reset-password-button": "Reset password",
"log-in-forgot-password-link": "Forgot your password?",
"log-in-sign-up-link": "Sign up with email",
"forgot-password-title": "Forgot password",
"reset-password-title": "Reset password",
"password-changed-hint": "Your password has been changed!"
}, },
"error": { "error": {
"bad-request-header": "Something was wrong with your input", "bad-request-header": "Something was wrong with your input",

View file

@ -25,7 +25,7 @@
<OwnProfileNotice editLink="/settings/profile" /> <OwnProfileNotice editLink="/settings/profile" />
{/if} {/if}
<ProfileHeader name="@{data.user.username}" profile={data.user} /> <ProfileHeader name="@{data.user.username}" profile={data.user} offset={data.user.utc_offset} />
<ProfileFields profile={data.user} {allPreferences} /> <ProfileFields profile={data.user} {allPreferences} />
{#if data.members.length > 0} {#if data.members.length > 0}

View file

@ -0,0 +1,34 @@
import { apiRequest, fastRequest } from "$api";
import ApiError from "$api/error.js";
import type { AuthUrls } from "$api/models/auth";
import log from "$lib/log.js";
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent, fetch }) => {
const { meUser } = await parent();
if (meUser) redirect(303, `/@${meUser.username}`);
const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true });
if (!urls.email_enabled) redirect(303, "/");
};
export const actions = {
default: async ({ request, fetch }) => {
const data = await request.formData();
const email = data.get("email") as string;
try {
await fastRequest("POST", "/auth/email/forgot-password", {
body: { email },
isInternal: true,
fetch,
});
return { ok: true, error: null };
} catch (e) {
if (e instanceof ApiError) return { ok: false, error: e.obj };
log.error("error sending forget password email:", e);
throw e;
}
},
};

View file

@ -0,0 +1,35 @@
<script lang="ts">
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import { t } from "$lib/i18n";
import type { ActionData } from "./$types";
type Props = { form: ActionData };
let { form }: Props = $props();
</script>
<svelte:head>
<title>{$t("auth.forgot-password-title")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<div class="mx-auto w-lg-50">
<h3>{$t("auth.forgot-password-title")}</h3>
<FormStatusMarker {form} successMessage={$t("auth.check-inbox-for-link-hint")} />
<form method="POST">
<label for="email" class="form-label">{$t("auth.log-in-form-email-label")}</label>
<input
required
type="email"
id="email"
name="email"
placeholder="me@example.com"
class="form-control mb-2"
/>
<div class="d-grid">
<button type="submit" class="btn btn-primary">{$t("auth.reset-password-button")}</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,48 @@
import { apiRequest, fastRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import type { AuthUrls } from "$api/models";
import log from "$lib/log";
import { redirect } from "@sveltejs/kit";
export const load = async ({ params, parent, fetch }) => {
const { meUser } = await parent();
if (meUser) redirect(303, `/@${meUser.username}`);
const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true });
if (!urls.email_enabled) redirect(303, "/");
return { state: params.code };
};
export const actions = {
default: async ({ request, fetch }) => {
const data = await request.formData();
const state = data.get("state") as string;
const password = data.get("password") as string;
const password2 = data.get("confirm-password") as string;
if (password !== password2) {
return {
ok: false,
error: {
status: 400,
message: "Passwords don't match",
code: ErrorCode.BadRequest,
} as RawApiError,
};
}
try {
await fastRequest("POST", "/auth/email/reset-password", {
body: { state, password },
isInternal: true,
fetch,
});
return { ok: true, error: null };
} catch (e) {
if (e instanceof ApiError) return { ok: false, error: e.obj };
log.error("error resetting password:", e);
throw e;
}
},
};

View file

@ -0,0 +1,41 @@
<script lang="ts">
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import { t } from "$lib/i18n";
import type { ActionData, PageData } from "./$types";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
</script>
<svelte:head>
<title>{$t("auth.reset-password-title")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<div class="mx-auto w-lg-50">
<h3>{$t("auth.reset-password-title")}</h3>
<FormStatusMarker {form} successMessage={$t("auth.password-changed-hint")} />
<form method="POST">
<input type="hidden" name="state" readonly value={data.state} />
<div class="mb-2">
<label for="password" class="form-label">{$t("auth.log-in-form-password-label")}</label>
<input required type="password" id="password" name="password" class="form-control" />
</div>
<div class="mb-2">
<label for="confirm-password" class="form-label">{$t("auth.confirm-password-label")}</label>
<input
required
type="password"
id="confirm-password"
name="confirm-password"
class="form-control"
/>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">{$t("auth.reset-password-button")}</button>
</div>
</form>
</div>
</div>

View file

@ -2,7 +2,6 @@
import type { ActionData, PageData } from "./$types"; import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import { Button, ButtonGroup, Input, InputGroup } from "@sveltestrap/sveltestrap";
import ErrorAlert from "$components/ErrorAlert.svelte"; import ErrorAlert from "$components/ErrorAlert.svelte";
type Props = { data: PageData; form: ActionData }; type Props = { data: PageData; form: ActionData };
@ -21,29 +20,34 @@
</div> </div>
<div class="row"> <div class="row">
{#if data.urls.email_enabled} {#if data.urls.email_enabled}
<div class="col col-md mb-4"> <div class="col-md mb-4">
<h2>{$t("auth.log-in-form-title")}</h2> <h2>{$t("auth.log-in-form-title")}</h2>
<form method="POST" action="?/login" use:enhance> <form method="POST" action="?/login" use:enhance>
<div class="mb-2"> <div class="mb-2">
<label class="form-label" for="email">{$t("auth.log-in-form-email-label")}</label> <label class="form-label" for="email">{$t("auth.log-in-form-email-label")}</label>
<Input type="email" id="email" name="email" placeholder="me@example.com" /> <input
class="form-control"
type="email"
id="email"
name="email"
placeholder="me@example.com"
/>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label" for="password">{$t("auth.log-in-form-password-label")}</label> <label class="form-label" for="password">{$t("auth.log-in-form-password-label")}</label>
<Input type="password" id="password" name="password" /> <input class="form-control" type="password" id="password" name="password" />
</div> </div>
<ButtonGroup> <button class="btn btn-primary" type="submit">{$t("auth.log-in-button")}</button>
<Button type="submit" color="primary">{$t("auth.log-in-button")}</Button>
<a class="btn btn-secondary" href="/auth/register">
{$t("auth.register-with-email-button")}
</a>
</ButtonGroup>
</form> </form>
<p class="mt-2">
<a href="/auth/register">{$t("auth.log-in-sign-up-link")}</a>
<a href="/auth/forgot-password">{$t("auth.log-in-forgot-password-link")}</a>
</p>
</div> </div>
{:else} {:else}
<div class="col-lg-3"></div> <div class="col-lg-3"></div>
{/if} {/if}
<div class="col col-md"> <div class="col-md">
<h3>{$t("auth.log-in-3rd-party-header")}</h3> <h3>{$t("auth.log-in-3rd-party-header")}</h3>
<p>{$t("auth.log-in-3rd-party-desc")}</p> <p>{$t("auth.log-in-3rd-party-desc")}</p>
<form method="POST" action="?/fediToggle" use:enhance> <form method="POST" action="?/fediToggle" use:enhance>
@ -71,19 +75,20 @@
{#if form?.showFediBox} {#if form?.showFediBox}
<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4> <h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4>
<form method="POST" action="?/fedi" use:enhance> <form method="POST" action="?/fedi" use:enhance>
<InputGroup> <div class="input-group">
<Input <input
class="form-control"
name="instance" name="instance"
type="text" type="text"
placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")} placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")}
/> />
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button> <button class="btn btn-secondary" type="submit">{$t("auth.log-in-button")}</button>
</InputGroup> </div>
<p> <p>
{$t("auth.log-in-with-fediverse-error-blurb")} {$t("auth.log-in-with-fediverse-error-blurb")}
<Button formaction="?/fediForceRefresh" type="submit" color="link"> <button class="btn btn-link" formaction="?/fediForceRefresh" type="submit">
{$t("auth.log-in-with-fediverse-force-refresh-button")} {$t("auth.log-in-with-fediverse-force-refresh-button")}
</Button> </button>
</p> </p>
</form> </form>
{/if} {/if}

View file

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { ActionData, PageData } from "./$types"; import type { ActionData } from "./$types";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap"; import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
type Props = { data: PageData; form: ActionData }; type Props = { form: ActionData };
let { data, form }: Props = $props(); let { form }: Props = $props();
</script> </script>
<svelte:head> <svelte:head>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { ActionData, PageData } from "./$types"; import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { Button, FormGroup, Icon, Input, InputGroup, Label } from "@sveltestrap/sveltestrap"; import { Button, FormGroup, Icon, Input, InputGroup } from "@sveltestrap/sveltestrap";
import Avatar from "$components/Avatar.svelte"; import Avatar from "$components/Avatar.svelte";
import { firstErrorFor } from "$api/error"; import { firstErrorFor } from "$api/error";
import Error from "$components/Error.svelte"; import Error from "$components/Error.svelte";

View file

@ -1,7 +1,6 @@
import { fastRequest } from "$api"; import { fastRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error.js"; import ApiError, { ErrorCode, type RawApiError } from "$api/error.js";
import log from "$lib/log.js"; import log from "$lib/log.js";
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => { export const load = async ({ parent }) => {
const { user } = await parent(); const { user } = await parent();

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { Button } from "@sveltestrap/sveltestrap";
import type { ActionData, PageData } from "./$types"; import type { ActionData, PageData } from "./$types";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";

View file

@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import type { ActionData, PageData } from "./$types"; import type { ActionData, PageData } from "./$types";
import ErrorAlert from "$components/ErrorAlert.svelte";
import { Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";

View file

@ -4,7 +4,7 @@ import type { PrideFlag } from "$api/models/user";
import log from "$lib/log"; import log from "$lib/log";
import { encode } from "base64-arraybuffer"; import { encode } from "base64-arraybuffer";
export const load = async ({ url, fetch, cookies }) => { export const load = async ({ fetch, cookies }) => {
const resp = await apiRequest<PrideFlag[]>("GET", "/users/@me/flags", { fetch, cookies }); const resp = await apiRequest<PrideFlag[]>("GET", "/users/@me/flags", { fetch, cookies });
return { return {

View file

@ -82,7 +82,7 @@ export const actions = {
}, },
options: async ({ params, request, fetch, cookies }) => { options: async ({ params, request, fetch, cookies }) => {
const body = await request.formData(); const body = await request.formData();
let unlisted = !!body.get("unlisted"); const unlisted = !!body.get("unlisted");
try { try {
await fastRequest("PATCH", `/users/@me/members/${params.id}`, { await fastRequest("PATCH", `/users/@me/members/${params.id}`, {

View file

@ -5,12 +5,11 @@
import { apiRequest, fastRequest } from "$api"; import { apiRequest, fastRequest } from "$api";
import ApiError from "$api/error"; import ApiError from "$api/error";
import log from "$lib/log"; import log from "$lib/log";
import { Icon, InputGroup } from "@sveltestrap/sveltestrap"; import { InputGroup } from "@sveltestrap/sveltestrap";
import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte"; import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import AvatarEditor from "$components/editor/AvatarEditor.svelte"; import AvatarEditor from "$components/editor/AvatarEditor.svelte";
import ErrorAlert from "$components/ErrorAlert.svelte"; import ErrorAlert from "$components/ErrorAlert.svelte";
import NoscriptWarning from "$components/editor/NoscriptWarning.svelte";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import SidEditor from "$components/editor/SidEditor.svelte"; import SidEditor from "$components/editor/SidEditor.svelte";
import BioEditor from "$components/editor/BioEditor.svelte"; import BioEditor from "$components/editor/BioEditor.svelte";

View file

@ -11,7 +11,7 @@ export const actions = {
let timezone = body.get("timezone") as string | null; let timezone = body.get("timezone") as string | null;
if (!timezone || timezone === "") timezone = null; if (!timezone || timezone === "") timezone = null;
let hideMemberList = !!body.get("hide-member-list"); const hideMemberList = !!body.get("hide-member-list");
try { try {
await fastRequest("PATCH", "/users/@me", { await fastRequest("PATCH", "/users/@me", {