1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-12 22:30:15 +00:00

feat: add ability to send login code via email (#457)

Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
This commit is contained in:
Elias Schneider
2025-04-20 18:32:40 +02:00
committed by GitHub
parent e571996cb5
commit fe1c4b18cd
22 changed files with 257 additions and 137 deletions

View File

@@ -156,7 +156,7 @@
"actions": "Actions",
"images_updated_successfully": "Images updated successfully",
"general": "General",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Enable email notifications to alert users when a login is detected from a new device or location.",
"configure_stmp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Images",
@@ -180,7 +180,10 @@
"enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Send test email",
"application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name",
@@ -334,5 +337,8 @@
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security."
}

View File

@@ -9,8 +9,10 @@
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import { axiosErrorToast } from '$lib/utils/error-util';
import { mode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
let {
userId = $bindable()
@@ -32,7 +34,7 @@
[m.one_month()]: 60 * 60 * 24 * 30
};
async function createOneTimeAccessToken() {
async function createLoginCode() {
try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
code = await userService.createOneTimeAccessToken(expiration, userId!);
@@ -42,6 +44,17 @@
}
}
async function sendLoginCodeEmail() {
try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
await userService.requestOneTimeAccessEmailAsAdmin(userId!, expiration);
toast.success(m.login_code_email_success());
onOpenChange(false);
} catch (e) {
axiosErrorToast(e);
}
}
function onOpenChange(open: boolean) {
if (!open) {
oneTimeLink = null;
@@ -81,13 +94,20 @@
</Select.Content>
</Select.Root>
</div>
<Button
onclick={() => createOneTimeAccessToken()}
disabled={!selectedExpiration}
class="mt-2 w-full"
>
{m.generate_code()}
</Button>
<Dialog.Footer class="mt-2">
{#if $appConfigStore.emailOneTimeAccessAsAdminEnabled}
<Button
onclick={() => sendLoginCodeEmail()}
variant="secondary"
disabled={!selectedExpiration}
>
{m.send_email()}
</Button>
{/if}
<Button onclick={() => createLoginCode()} disabled={!selectedExpiration}
>{m.show_code()}</Button
>
</Dialog.Footer>
{:else}
<div class="flex flex-col items-center gap-2">
<CopyToClipboard value={code!}>

View File

@@ -87,10 +87,14 @@ export default class UserService extends APIService {
return res.data as User;
}
async requestOneTimeAccessEmail(email: string, redirectPath?: string) {
async requestOneTimeAccessEmailAsUnauthenticatedUser(email: string, redirectPath?: string) {
await this.api.post('/one-time-access-email', { email, redirectPath });
}
async requestOneTimeAccessEmailAsAdmin(userId: string, expiresAt: Date) {
await this.api.post(`/users/${userId}/one-time-access-email`, { expiresAt });
}
async updateUserGroups(id: string, userGroupIds: string[]) {
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
return res.data as User;

View File

@@ -1,7 +1,8 @@
export type AppConfig = {
appName: string;
allowOwnAccountEdit: boolean;
emailOneTimeAccessEnabled: boolean;
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
emailOneTimeAccessAsAdminEnabled: boolean;
ldapEnabled: boolean;
disableAnimations: boolean;
};

View File

@@ -17,7 +17,7 @@
}
];
if ($appConfigStore.emailOneTimeAccessEnabled) {
if ($appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled) {
methods.push({
icon: LucideMail,
title: m.email_login(),

View File

@@ -73,7 +73,7 @@
id="application-configuration-email"
icon={Mail}
title={m.email()}
description={m.enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location()}
description={m.configure_stmp_to_send_emails()}
>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>

View File

@@ -39,7 +39,8 @@
smtpFrom: z.string().email(),
smtpTls: z.enum(['none', 'starttls', 'tls']),
smtpSkipCertVerify: z.boolean(),
emailOneTimeAccessEnabled: z.boolean(),
emailOneTimeAccessAsUnauthenticatedEnabled: z.boolean(),
emailOneTimeAccessAsAdminEnabled: z.boolean(),
emailLoginNotificationEnabled: z.boolean()
});
@@ -88,9 +89,7 @@
await appConfigService
.sendTestEmail()
.then(() => toast.success(m.test_email_sent_successfully()))
.catch(() =>
toast.error(m.failed_to_send_test_email())
)
.catch(() => toast.error(m.failed_to_send_test_email()))
.finally(() => (isSendingTestEmail = false));
}
</script>
@@ -136,10 +135,16 @@
bind:checked={$inputs.emailLoginNotificationEnabled.value}
/>
<CheckboxWithLabel
id="email-login"
label={m.email_login()}
id="email-login-user"
label={m.emai_login_code_requested_by_user()}
description={m.allow_users_to_sign_in_with_a_login_code_sent_to_their_email()}
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
bind:checked={$inputs.emailOneTimeAccessAsUnauthenticatedEnabled.value}
/>
<CheckboxWithLabel
id="email-login-admin"
label={m.email_login_code_from_admin()}
description={m.allows_an_admin_to_send_a_login_code_to_the_user()}
bind:checked={$inputs.emailOneTimeAccessAsAdminEnabled.value}
/>
</div>
</fieldset>

View File

@@ -161,4 +161,4 @@
{/snippet}
</AdvancedTable>
<OneTimeLinkModal userId={userIdToCreateOneTimeLink} />
<OneTimeLinkModal bind:userId={userIdToCreateOneTimeLink} />

View File

@@ -32,7 +32,8 @@ test('Update email configuration', async ({ page }) => {
await page.getByLabel('SMTP Password').fill('password');
await page.getByLabel('SMTP From').fill('test@gmail.com');
await page.getByLabel('Email Login Notification').click();
await page.getByLabel('Email Login', { exact: true }).click();
await page.getByLabel('Email Login Code Requested by User').click();
await page.getByLabel('Email Login Code from Admin').click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
@@ -46,7 +47,8 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('SMTP Password')).toHaveValue('password');
await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com');
await expect(page.getByLabel('Email Login Notification')).toBeChecked();
await expect(page.getByLabel('Email Login', { exact: true })).toBeChecked();
await expect(page.getByLabel('Email Login Code Requested by User')).toBeChecked();
await expect(page.getByLabel('Email Login Code from Admin')).toBeChecked();
});
test('Update LDAP configuration', async ({ page }) => {

View File

@@ -64,7 +64,7 @@ test('Create one time access token', async ({ page, context }) => {
await page.getByLabel('Login Code').getByRole('combobox').click();
await page.getByRole('option', { name: '12 hours' }).click();
await page.getByRole('button', { name: 'Generate Code' }).click();
await page.getByRole('button', { name: 'Show Code' }).click();
const link = await page.getByTestId('login-code-link').textContent();
await context.clearCookies();