mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +00:00
feat: add password reset flow using configured SMTP settings (#7525)
* feat: add password reset flow using configured SMTP settings Implements password reset functionality for users with email/password login: Backend: - Add `/auth/request_password_reset` endpoint to request a password reset email - Add `/auth/reset_password` endpoint to reset password using token - Add `/auth/is_smtp_configured` endpoint to check if SMTP is available - Uses existing `magic_link` table for storing reset tokens - Tokens expire after 1 hour - Invalidates all existing sessions on password reset - Includes audit logging Frontend: - Add "Forgot password?" link on login page (shown when SMTP is configured) - Add `/user/forgot-password` page for requesting password reset - Add `/user/reset-password` page for entering new password - Both pages follow existing Windmill design patterns Security: - Always returns success response to prevent email enumeration - Password must be at least 8 characters - Uses argon2 for password hashing (same as existing login) Closes #7524 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> * nits * nits * fix oss * nits * fix oss --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: HugoCasa <hugo@casademont.ch> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
co-authored by
windmill-internal-app[bot]
claude[bot]
HugoCasa
Ruben Fiszel
parent
7a86ea154e
commit
6f7cf2fb16
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE password SET password_hash = $1 WHERE email = $2 AND login_type = 'password'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "31922d7aaaaf17f389d489b9a746295d6c3ad8ac6750782bd9ab35a9b432ca6b"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM magic_link WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "97a83839e5d9269e9389b9c7604814cc245cc8d4ae653cfce0f2ccca4ee630cb"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO magic_link (email, token, expiration) VALUES ($1, $2, NOW() + INTERVAL '1 hour')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a61d53c8400864a7bc06894c08ec70e45242075bd17b37ea2c0c6b6eec11eb40"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM password WHERE email = $1 AND login_type = 'password')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c1b6a2c3605cf5385664c5f988b96297fe6b8971e388ea036d5355e0c0937006"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email FROM magic_link WHERE token = $1 AND expiration > NOW()",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fd500c52e64983a5559da8bcd0d5ed43a9f2eba45a7eec6b64ab38ea02d6b6c9"
|
||||
}
|
||||
@@ -230,6 +230,83 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/auth/is_smtp_configured:
|
||||
get:
|
||||
security: []
|
||||
summary: check if SMTP is configured for password reset
|
||||
operationId: isSmtpConfigured
|
||||
tags:
|
||||
- user
|
||||
responses:
|
||||
"200":
|
||||
description: returns true if SMTP is configured
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
/auth/request_password_reset:
|
||||
post:
|
||||
security: []
|
||||
summary: request password reset email
|
||||
operationId: requestPasswordReset
|
||||
tags:
|
||||
- user
|
||||
requestBody:
|
||||
description: email to send password reset link to
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- email
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
responses:
|
||||
"200":
|
||||
description: password reset email sent (if user exists)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PasswordResetResponse"
|
||||
"400":
|
||||
description: SMTP not configured
|
||||
|
||||
/auth/reset_password:
|
||||
post:
|
||||
security: []
|
||||
summary: reset password using token
|
||||
operationId: resetPassword
|
||||
tags:
|
||||
- user
|
||||
requestBody:
|
||||
description: token and new password
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- token
|
||||
- new_password
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
new_password:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: password reset successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PasswordResetResponse"
|
||||
"400":
|
||||
description: invalid or expired token
|
||||
|
||||
/w/{workspace}/users/get/{username}:
|
||||
get:
|
||||
summary: get user (require admin privilege)
|
||||
@@ -17457,6 +17534,14 @@ components:
|
||||
- email
|
||||
- password
|
||||
|
||||
PasswordResetResponse:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- message
|
||||
|
||||
EditWorkspaceUser:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -53,6 +53,7 @@ use windmill_common::users::COOKIE_NAME;
|
||||
use windmill_common::users::{truncate_token, username_to_permissioned_as};
|
||||
use windmill_common::utils::paginate;
|
||||
use windmill_common::worker::CLOUD_HOSTED;
|
||||
use windmill_common::BASE_URL;
|
||||
use windmill_common::{
|
||||
auth::{get_folders_for_user, get_groups_for_user},
|
||||
db::UserDB,
|
||||
@@ -125,6 +126,9 @@ pub fn make_unauthed_service() -> Router {
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout).get(logout))
|
||||
.route("/is_first_time_setup", get(is_first_time_setup))
|
||||
.route("/request_password_reset", post(request_password_reset))
|
||||
.route("/reset_password", post(reset_password))
|
||||
.route("/is_smtp_configured", get(is_smtp_configured))
|
||||
}
|
||||
|
||||
pub async fn maybe_refresh_folders(
|
||||
@@ -3081,3 +3085,197 @@ async fn update_username_in_workpsace<'c>(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Password Reset Types
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestPasswordReset {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetPassword {
|
||||
pub token: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PasswordResetResponse {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// Password Reset Functions
|
||||
|
||||
/// Check if SMTP is configured
|
||||
async fn is_smtp_configured(Extension(db): Extension<DB>) -> JsonResult<bool> {
|
||||
let smtp = windmill_common::server::load_smtp_config(&db).await?;
|
||||
Ok(Json(smtp.is_some()))
|
||||
}
|
||||
|
||||
/// Request a password reset email
|
||||
async fn request_password_reset(
|
||||
Extension(db): Extension<DB>,
|
||||
Json(req): Json<RequestPasswordReset>,
|
||||
) -> Result<Json<PasswordResetResponse>> {
|
||||
let email = req.email.to_lowercase();
|
||||
|
||||
// Check if SMTP is configured
|
||||
let smtp = windmill_common::server::load_smtp_config(&db).await?;
|
||||
let smtp = smtp.ok_or_else(|| {
|
||||
Error::BadRequest("SMTP is not configured. Password reset is not available.".to_string())
|
||||
})?;
|
||||
|
||||
// Check if user exists with password login type
|
||||
let user_exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1 AND login_type = 'password')",
|
||||
&email
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
// But only send email if user exists
|
||||
if user_exists {
|
||||
// Generate a secure token
|
||||
let token = rd_string(32);
|
||||
|
||||
// Delete any existing tokens for this email
|
||||
sqlx::query!("DELETE FROM magic_link WHERE email = $1", &email)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Insert new token with 1 hour expiration
|
||||
sqlx::query!(
|
||||
"INSERT INTO magic_link (email, token, expiration) VALUES ($1, $2, NOW() + INTERVAL '1 hour')",
|
||||
&email,
|
||||
&token
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Get the base URL for the reset link
|
||||
let base_url = BASE_URL.read().await.clone();
|
||||
let base_url = if base_url.is_empty() {
|
||||
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string())
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
|
||||
let reset_link = format!("{}/user/reset-password?token={}", base_url, token);
|
||||
|
||||
// Send the email
|
||||
let subject = "Windmill Password Reset";
|
||||
let content = format!(
|
||||
"You have requested a password reset for your Windmill account.\n\n\
|
||||
Click the link below to reset your password:\n\
|
||||
{}\n\n\
|
||||
This link will expire in 1 hour.\n\n\
|
||||
If you did not request this password reset, you can safely ignore this email.",
|
||||
reset_link
|
||||
);
|
||||
|
||||
// Send the email - don't fail the request if email fails
|
||||
if let Err(e) = windmill_common::email_oss::send_email_plain_text(
|
||||
subject,
|
||||
&content,
|
||||
vec![email.clone()],
|
||||
smtp,
|
||||
Some(Duration::from_secs(10)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to send password reset email to {}: {:?}", email, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
Ok(Json(PasswordResetResponse {
|
||||
message: "If an account with that email exists, a password reset link has been sent."
|
||||
.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reset password using a token
|
||||
async fn reset_password(
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(argon2): Extension<Arc<Argon2<'_>>>,
|
||||
Json(req): Json<ResetPassword>,
|
||||
) -> Result<Json<PasswordResetResponse>> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Find the token and verify it's not expired
|
||||
let magic_link = sqlx::query!(
|
||||
"SELECT email FROM magic_link WHERE token = $1 AND expiration > NOW()",
|
||||
&req.token
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let email = match magic_link {
|
||||
Some(link) => link.email,
|
||||
None => {
|
||||
return Err(Error::BadRequest(
|
||||
"Invalid or expired password reset token".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Hash the new password
|
||||
let password_hash = crate::users_oss::hash_password(argon2, req.new_password)?;
|
||||
|
||||
// Update the password
|
||||
let rows_updated = sqlx::query!(
|
||||
"UPDATE password SET password_hash = $1 WHERE email = $2 AND login_type = 'password'",
|
||||
&password_hash,
|
||||
&email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows_updated == 0 {
|
||||
return Err(Error::BadRequest(
|
||||
"Unable to update password. User may not exist or may use a different login method."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Delete the used token and any other tokens for this email
|
||||
sqlx::query!("DELETE FROM magic_link WHERE email = $1", &email)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Invalidate all existing sessions for this user
|
||||
sqlx::query!(
|
||||
"DELETE FROM token WHERE email = $1 AND label = 'session'",
|
||||
&email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Audit log
|
||||
let audit_author = AuditAuthor {
|
||||
email: email.clone(),
|
||||
username: email.clone(),
|
||||
username_override: None,
|
||||
token_prefix: None,
|
||||
};
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&audit_author,
|
||||
"users.password_reset",
|
||||
ActionKind::Update,
|
||||
"global",
|
||||
Some(&email),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(PasswordResetResponse {
|
||||
message: "Password has been reset successfully. You can now log in with your new password."
|
||||
.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ pub async fn set_password(
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn hash_password(_argon2: Arc<Argon2<'_>>, _password: String) -> Result<String> {
|
||||
Err(Error::internal_err(
|
||||
"Not implemented in Windmill's Open Source repository".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn send_email_if_possible(_subject: &str, _content: &str, _to: &str) {
|
||||
tracing::warn!(
|
||||
@@ -70,7 +77,6 @@ pub struct OnboardingData {
|
||||
pub use_case: String,
|
||||
}
|
||||
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn submit_onboarding_data(
|
||||
_authed: ApiAuthed,
|
||||
@@ -80,4 +86,4 @@ pub async fn submit_onboarding_data(
|
||||
Err(Error::internal_err(
|
||||
"Not implemented in Windmill's Open Source repository".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
let showPassword = $state(false)
|
||||
let logins: OAuthLogin[] | undefined = $state(undefined)
|
||||
let saml: string | undefined = $state(undefined)
|
||||
let smtpConfigured: boolean | undefined = $state(undefined)
|
||||
|
||||
type OAuthLogin = {
|
||||
type: string
|
||||
@@ -194,6 +195,17 @@
|
||||
|
||||
loadLogins()
|
||||
|
||||
async function checkSmtpConfigured() {
|
||||
try {
|
||||
smtpConfigured = await UserService.isSmtpConfigured()
|
||||
} catch (err) {
|
||||
console.error('Could not check if SMTP is configured', err)
|
||||
smtpConfigured = false
|
||||
}
|
||||
}
|
||||
|
||||
checkSmtpConfigured()
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
const key = event.key
|
||||
|
||||
@@ -372,6 +384,16 @@
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{#if smtpConfigured}
|
||||
<div class="text-right pt-1">
|
||||
<a
|
||||
href="{base}/user/forgot-password"
|
||||
class="text-2xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$lib/navigation'
|
||||
import { WindmillIcon } from '$lib/components/icons'
|
||||
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { UserService } from '$lib/gen'
|
||||
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
|
||||
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
|
||||
|
||||
let email = $state('')
|
||||
let loading = $state(false)
|
||||
let submitted = $state(false)
|
||||
|
||||
async function requestPasswordReset() {
|
||||
if (!email) {
|
||||
sendUserToast('Please enter your email address', true)
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
try {
|
||||
await UserService.requestPasswordReset({ requestBody: { email } })
|
||||
submitted = true
|
||||
sendUserToast('If an account with that email exists, a password reset link has been sent.')
|
||||
} catch (err: any) {
|
||||
if (err?.body?.includes('SMTP is not configured')) {
|
||||
sendUserToast('Password reset is not available. SMTP is not configured.', true)
|
||||
} else {
|
||||
sendUserToast('An error occurred. Please try again later.', true)
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
requestPasswordReset()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative bg-surface-secondary h-screen"
|
||||
>
|
||||
<LoginPageHeader />
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div class="mx-auto flex justify-center">
|
||||
{#if !$enterpriseLicense || !$whitelabelNameStore}
|
||||
<WindmillIcon height="80px" width="80px" spin="slow" />
|
||||
{/if}
|
||||
</div>
|
||||
<h2 class="mt-6 text-center text-2xl font-semibold tracking-tight text-emphasis">
|
||||
Reset password
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-xs text-secondary">
|
||||
Enter your email address and we'll send you a link to reset your password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-xl mb-48">
|
||||
<div class="flex justify-end">
|
||||
<DarkModeToggle forcedDarkMode={false} />
|
||||
</div>
|
||||
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
|
||||
{#if submitted}
|
||||
<div class="text-center space-y-4">
|
||||
<p class="text-secondary">
|
||||
If an account with that email exists, we've sent a password reset link.
|
||||
</p>
|
||||
<p class="text-secondary text-sm">
|
||||
Please check your email and follow the instructions to reset your password.
|
||||
</p>
|
||||
<div class="pt-4">
|
||||
<Button variant="accent" on:click={() => goto('/user/login')}>Back to login</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<label for="email" class="block text-xs font-semibold text-emphasis">Email</label>
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
id="email"
|
||||
autocomplete="email"
|
||||
onkeyup={handleKeyUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-2 flex flex-col gap-2">
|
||||
<Button on:click={requestPasswordReset} variant="accent" disabled={!email || loading}>
|
||||
{loading ? 'Sending...' : 'Send reset link'}
|
||||
</Button>
|
||||
<Button variant="subtle" on:click={() => goto('/user/login')}>Back to login</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { WindmillIcon } from '$lib/components/icons'
|
||||
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { UserService } from '$lib/gen'
|
||||
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
|
||||
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
|
||||
|
||||
const token = $page.url.searchParams.get('token') ?? ''
|
||||
|
||||
let newPassword = $state('')
|
||||
let confirmPassword = $state('')
|
||||
let loading = $state(false)
|
||||
let success = $state(false)
|
||||
|
||||
async function resetPassword() {
|
||||
if (!token) {
|
||||
sendUserToast('Invalid or missing reset token', true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!newPassword || !confirmPassword) {
|
||||
sendUserToast('Please fill in both password fields', true)
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
sendUserToast('Passwords do not match', true)
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
try {
|
||||
await UserService.resetPassword({
|
||||
requestBody: {
|
||||
token,
|
||||
new_password: newPassword
|
||||
}
|
||||
})
|
||||
success = true
|
||||
sendUserToast('Password has been reset successfully!')
|
||||
} catch (err: any) {
|
||||
console.error('Could not reset password', err)
|
||||
sendUserToast('Could not reset password: ' + err, true)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
resetPassword()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative bg-surface-secondary h-screen"
|
||||
>
|
||||
<LoginPageHeader />
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div class="mx-auto flex justify-center">
|
||||
{#if !$enterpriseLicense || !$whitelabelNameStore}
|
||||
<WindmillIcon height="80px" width="80px" spin="slow" />
|
||||
{/if}
|
||||
</div>
|
||||
<h2 class="mt-6 text-center text-2xl font-semibold tracking-tight text-emphasis">
|
||||
{success ? 'Password Reset' : 'Set New Password'}
|
||||
</h2>
|
||||
{#if !success}
|
||||
<p class="mt-2 text-center text-xs text-secondary"> Enter your new password below </p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-xl mb-48">
|
||||
<div class="flex justify-end">
|
||||
<DarkModeToggle forcedDarkMode={false} />
|
||||
</div>
|
||||
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
|
||||
{#if !token}
|
||||
<div class="text-center space-y-4">
|
||||
<p class="text-red-500">Invalid or missing reset token.</p>
|
||||
<div class="pt-4">
|
||||
<Button variant="accent" on:click={() => goto('/user/forgot-password')}>
|
||||
Request New Reset Link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if success}
|
||||
<div class="text-center space-y-4">
|
||||
<p class="text-secondary"> Your password has been reset successfully. </p>
|
||||
<p class="text-secondary text-sm"> You can now log in with your new password. </p>
|
||||
<div class="pt-4">
|
||||
<Button variant="accent" on:click={() => goto('/user/login')}>Go to login</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<label for="new-password" class="block text-xs font-semibold text-emphasis">
|
||||
New Password
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
id="new-password"
|
||||
autocomplete="new-password"
|
||||
onkeyup={handleKeyUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="confirm-password" class="block text-xs font-semibold text-emphasis">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
id="confirm-password"
|
||||
autocomplete="new-password"
|
||||
onkeyup={handleKeyUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-2 flex flex-col gap-2">
|
||||
<Button
|
||||
on:click={resetPassword}
|
||||
variant="accent"
|
||||
disabled={!newPassword || !confirmPassword || loading}
|
||||
>
|
||||
{loading ? 'Resetting...' : 'Reset password'}
|
||||
</Button>
|
||||
<Button variant="subtle" on:click={() => goto('/user/login')}>Back to login</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user