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
5 changes: 5 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@
"auth_send_reset_link": "Send Reset Link",
"auth_sending": "Sending…",
"auth_enter_email": "Please enter your email address.",
"auth_update_password_page_title": "Update Password - CropWatch",
"auth_update_password_heading": "Set New Password",
"auth_update_password_subtitle": "Enter your new password below.",
"auth_update_password_confirm_label": "CONFIRM PASSWORD",
"auth_update_password_submit": "Update Password",
"locations_all_title": "All Locations",
"locations_all_subtitle": "Aggregated from current device telemetry",
"locations_add_location": "Add Location",
Expand Down
5 changes: 5 additions & 0 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@
"auth_send_reset_link": "再設定リンクを送信",
"auth_sending": "送信中…",
"auth_enter_email": "メールアドレスを入力してください。",
"auth_update_password_page_title": "パスワード更新 - CropWatch",
"auth_update_password_heading": "新しいパスワードを設定",
"auth_update_password_subtitle": "新しいパスワードを入力してください。",
"auth_update_password_confirm_label": "パスワード確認",
"auth_update_password_submit": "パスワードを更新",
"locations_all_title": "すべてのロケーション",
"locations_all_subtitle": "現在のデバイステレメトリから集計",
"locations_add_location": "ロケーションを追加",
Expand Down
33 changes: 33 additions & 0 deletions src/routes/auth/confirm/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { EmailOtpType } from '@supabase/supabase-js';
import { redirect } from '@sveltejs/kit';
import { getSupabaseClient } from '$lib/supabase.server';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ url, cookies }) => {
const token_hash = url.searchParams.get('token_hash');
const type = url.searchParams.get('type') as EmailOtpType | null;
const next = url.searchParams.get('next') ?? '/auth/login';

if (token_hash && type) {
const supabase = getSupabaseClient();
const { data, error } = await supabase.auth.verifyOtp({ type, token_hash });

if (!error && data.session) {
// For recovery flow, store the access token so the update-password page can use it
if (type === 'recovery') {
cookies.set('supabase_recovery_token', data.session.access_token, {
path: '/',
httpOnly: true,
secure: url.protocol === 'https:',
sameSite: 'lax',
maxAge: 600 // 10 minutes
});
}

redirect(303, next);
}
}

// Token invalid or missing — send to an error-aware login page
redirect(303, '/auth/login?reason=auth-required');
};
58 changes: 58 additions & 0 deletions src/routes/auth/update-password/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createClient } from '@supabase/supabase-js';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';

export const load: PageServerLoad = async ({ cookies }) => {
const token = cookies.get('supabase_recovery_token');
if (!token) {
redirect(303, '/auth/login?reason=auth-required');
}
return {};
};

export const actions: Actions = {
default: async ({ request, cookies }) => {
const token = cookies.get('supabase_recovery_token');
if (!token) {
redirect(303, '/auth/login?reason=auth-required');
}

const data = await request.formData();
const password = data.get('password') as string;
const confirmPassword = data.get('confirmPassword') as string;

if (!password || password.length < 6) {
return fail(400, { message: 'Password must be at least 6 characters.' });
}

if (password !== confirmPassword) {
return fail(400, { message: 'Passwords do not match.' });
}

// Create a Supabase client authenticated with the recovery access token
const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false
},
global: {
headers: {
Authorization: `Bearer ${token}`
}
}
});

const { error } = await supabase.auth.updateUser({ password });

if (error) {
console.error('Password update error:', error);
return fail(500, { message: 'Failed to update password. The link may have expired.' });
}

// Clear the recovery token cookie
cookies.delete('supabase_recovery_token', { path: '/' });

redirect(303, '/auth/login?reason=password-reset');
}
};
173 changes: 173 additions & 0 deletions src/routes/auth/update-password/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import logo from '$lib/images/cropwatch_static.svg';
import KEY_ICON from '$lib/images/icons/key.svg';
import BACK_ICON from '$lib/images/icons/back.svg';
import { resolve } from '$app/paths';
import { applyAction, enhance } from '$app/forms';
import { isStrongPassword } from '$lib/utils/strongPasswordCheck';
import { CwButton, CwCard, CwInput, useCwToast } from '@cropwatchdevelopment/cwui';
import { m } from '$lib/paraglide/messages.js';
import '../login/style.css';

interface Props {
form: { message?: string } | null;
}

const toast = useCwToast();

let { form }: Props = $props();

let submitting: boolean = $state(false);
let password: string = $state('');
let confirmPassword: string = $state('');

let strength = $derived(isStrongPassword(password));
let passwordsMatch = $derived(confirmPassword.length > 0 && password === confirmPassword);
let passwordsMismatch = $derived(confirmPassword.length > 0 && password !== confirmPassword);
</script>

<svelte:head>
<title>{m.auth_update_password_page_title()}</title>
</svelte:head>

<CwCard padded={false} class="auth-card">
<div class="auth-shell">
<div class="logo-frame">
<img src={logo} alt={m.app_name()} class="logo-image" />
</div>

<h1 class="auth-title">{m.auth_update_password_heading()}</h1>
<p class="auth-subtitle">{m.auth_update_password_subtitle()}</p>

<form
method="POST"
class="auth-form"
use:enhance={() => {
if (submitting) return;
submitting = true;

return async ({ result }) => {
if (result.type === 'failure' && typeof result.data?.message === 'string') {
toast.add({ message: result.data.message, tone: 'danger' });
}
submitting = false;
await applyAction(result);
};
}}
>
<label class="field-block">
<span class="field-label">{m.auth_password_label()}</span>
<CwInput
class="auth-input"
name="password"
type="password"
placeholder={m.auth_password_placeholder()}
autocomplete="new-password"
required
bind:value={password}
/>
</label>

{#if password.length > 0}
<div class="password-requirements">
<p class="req-heading">{m.auth_password_requirements()}</p>
<ul class="req-list">
<li class:met={strength.hasMinLength}>{m.auth_password_requirement_length()}</li>
<li class:met={strength.hasLowerCase}>{m.auth_password_requirement_lowercase()}</li>
<li class:met={strength.hasUpperCase}>{m.auth_password_requirement_uppercase()}</li>
<li class:met={strength.hasNumber}>{m.auth_password_requirement_number()}</li>
<li class:met={strength.hasSymbol}>{m.auth_password_requirement_symbol()}</li>
</ul>
</div>
{/if}

<label class="field-block">
<span class="field-label">{m.auth_update_password_confirm_label()}</span>
<CwInput
class="auth-input"
name="confirmPassword"
type="password"
placeholder={m.auth_password_placeholder()}
autocomplete="new-password"
required
bind:value={confirmPassword}
/>
</label>

{#if passwordsMatch}
<p class="match-ok">{m.auth_passwords_match()}</p>
{:else if passwordsMismatch}
<p class="match-err">{m.auth_passwords_do_not_match()}</p>
{/if}

<CwButton
type="submit"
class="auth-primary"
block
disabled={submitting || !strength.isValid || !passwordsMatch}
>
<Icon src={KEY_ICON} alt="" class="h-4 w-4" />
{submitting ? m.auth_sending() : m.auth_update_password_submit()}
</CwButton>
</form>

<a class="auth-link" href={resolve('/auth/login')}>
<Icon src={BACK_ICON} alt="" class="h-4 w-4" />
{m.auth_back_to_login()}
</a>
</div>
</CwCard>

<style>
.password-requirements {
font-size: 0.82rem;
color: rgb(158 176 205);
}

.req-heading {
margin: 0 0 0.3rem;
font-weight: 600;
}

.req-list {
margin: 0;
padding-left: 1.2rem;
list-style: disc;
}

.req-list li {
color: rgb(220 120 120);
}

.req-list li.met {
color: rgb(100 200 130);
}

.match-ok {
margin: 0;
font-size: 0.85rem;
color: rgb(100 200 130);
}

.match-err {
margin: 0;
font-size: 0.85rem;
color: rgb(220 120 120);
}

.auth-link {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
margin-top: 1.2rem;
font-size: 0.9rem;
color: rgb(140 175 220);
text-decoration: none;
}

.auth-link:hover {
color: rgb(180 210 255);
}
</style>
Loading