mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-17 01:11:38 +03:00
feat(account): add ability to sign in with login code (#271)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -224,3 +224,10 @@ func (e *InvalidUUIDError) Error() string {
|
||||
}
|
||||
|
||||
type InvalidEmailError struct{}
|
||||
|
||||
type OneTimeAccessDisabledError struct{}
|
||||
|
||||
func (e *OneTimeAccessDisabledError) Error() string {
|
||||
return "One-time access is disabled"
|
||||
}
|
||||
func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
@@ -27,7 +27,7 @@ func NewAppConfigController(
|
||||
}
|
||||
group.GET("/application-configuration", acc.listAppConfigHandler)
|
||||
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
|
||||
group.PUT("/application-configuration", acc.updateAppConfigHandler)
|
||||
group.PUT("/application-configuration", jwtAuthMiddleware.Add(true), acc.updateAppConfigHandler)
|
||||
|
||||
group.GET("/application-configuration/logo", acc.getLogoHandler)
|
||||
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
|
||||
|
||||
@@ -38,7 +38,8 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
|
||||
group.PUT("/users/:id/profile-picture", jwtAuthMiddleware.Add(true), uc.updateUserProfilePictureHandler)
|
||||
group.PUT("/users/me/profile-picture", jwtAuthMiddleware.Add(false), uc.updateCurrentUserProfilePictureHandler)
|
||||
|
||||
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
|
||||
group.POST("/users/me/one-time-access-token", jwtAuthMiddleware.Add(false), uc.createOwnOneTimeAccessTokenHandler)
|
||||
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createAdminOneTimeAccessTokenHandler)
|
||||
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
|
||||
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
|
||||
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
|
||||
@@ -235,13 +236,16 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
|
||||
var input dto.OneTimeAccessTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if own {
|
||||
input.UserID = c.GetString("userID")
|
||||
}
|
||||
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
@@ -251,6 +255,14 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"token": token})
|
||||
}
|
||||
|
||||
func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
uc.createOneTimeAccessTokenHandler(c, true)
|
||||
}
|
||||
|
||||
func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
uc.createOneTimeAccessTokenHandler(c, false)
|
||||
}
|
||||
|
||||
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
|
||||
var input dto.OneTimeAccessEmailDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
|
||||
@@ -24,7 +24,7 @@ type UserCreateDto struct {
|
||||
}
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
UserID string `json:"userId"`
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
||||
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
|
||||
Path: "one-time-access",
|
||||
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
|
||||
return "One time access"
|
||||
return "Login Code"
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ type NewLoginTemplateData struct {
|
||||
}
|
||||
|
||||
type OneTimeAccessTemplateData = struct {
|
||||
Link string
|
||||
Code string
|
||||
LoginLink string
|
||||
LoginLinkWithCode string
|
||||
}
|
||||
|
||||
// this is list of all template paths used for preloading templates
|
||||
|
||||
@@ -197,6 +197,11 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
|
||||
}
|
||||
|
||||
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
|
||||
isDisabled := s.appConfigService.DbConfig.EmailOneTimeAccessEnabled.Value != "true"
|
||||
if isDisabled {
|
||||
return &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
|
||||
// Do not return error if user not found to prevent email enumeration
|
||||
@@ -207,17 +212,18 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
|
||||
}
|
||||
}
|
||||
|
||||
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
|
||||
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(15*time.Minute))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("%s/login/%s", common.EnvConfig.AppURL, oneTimeAccessToken)
|
||||
link := fmt.Sprintf("%s/lc", common.EnvConfig.AppURL)
|
||||
linkWithCode := fmt.Sprintf("%s/%s", link, oneTimeAccessToken)
|
||||
|
||||
// Add redirect path to the link
|
||||
if strings.HasPrefix(redirectPath, "/") {
|
||||
encodedRedirectPath := url.QueryEscape(redirectPath)
|
||||
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
|
||||
linkWithCode = fmt.Sprintf("%s?redirect=%s", linkWithCode, encodedRedirectPath)
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -225,7 +231,9 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
|
||||
Name: user.Username,
|
||||
Email: user.Email,
|
||||
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
||||
Link: link,
|
||||
Code: oneTimeAccessToken,
|
||||
LoginLink: link,
|
||||
LoginLinkWithCode: linkWithCode,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
|
||||
@@ -236,7 +244,14 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
|
||||
}
|
||||
|
||||
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||
tokenLength := 16
|
||||
|
||||
// If expires at is less than 15 minutes, use an 6 character token instead of 16
|
||||
if expiresAt.Sub(time.Now()) <= 15*time.Minute {
|
||||
tokenLength = 6
|
||||
}
|
||||
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>One-Time Access</h2>
|
||||
<h2>Login Code</h2>
|
||||
<p class="message">
|
||||
Click the button below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
|
||||
Click the button below to sign in to {{ .AppName }} with a login code.</br>Or visit <a href="{{ .Data.LoginLink }}">{{ .Data.LoginLink }}</a> and enter the code <strong>{{ .Data.Code }}</strong>.</br></br>This code expires in 15 minutes.
|
||||
</p>
|
||||
<div class="button-container">
|
||||
<a class="button" href="{{ .Data.Link }}" class="button">Sign In</a>
|
||||
<a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ end -}}
|
||||
@@ -1,8 +1,10 @@
|
||||
{{ define "base" -}}
|
||||
One-Time Access
|
||||
Login Code
|
||||
====================
|
||||
|
||||
Click the link below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
|
||||
Click the link below to sign in to {{ .AppName }} with a login code. This code expires in 15 minutes.
|
||||
|
||||
{{ .Data.Link }}
|
||||
{{ .Data.LoginLinkWithCode }}
|
||||
|
||||
Or visit {{ .Data.LoginLink }} and enter the the code "{{ .Data.Code }}".
|
||||
{{ end -}}
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.35.2",
|
||||
"version": "0.35.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.35.2",
|
||||
"version": "0.35.3",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.1.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||
|
||||
const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login');
|
||||
const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/lc')
|
||||
const isPublicPath = ['/authorize', '/health'].includes(event.url.pathname);
|
||||
const isAdminPath = event.url.pathname.startsWith('/settings/admin');
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</script>
|
||||
|
||||
<Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}>
|
||||
<Tooltip.Trigger class="text-start" onclick={onClick}>{@render children()}</Tooltip.Trigger>
|
||||
<Tooltip.Trigger class="text-start" tabindex={-1} onclick={onClick}>{@render children()}</Tooltip.Trigger>
|
||||
<Tooltip.Content onclick={copyToClipboard}>
|
||||
{#if copied}
|
||||
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
|
||||
|
||||
@@ -1,46 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
|
||||
import { page } from '$app/state';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Button } from './ui/button';
|
||||
import * as Card from './ui/card';
|
||||
import WebAuthnUnsupported from './web-authn-unsupported.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let {
|
||||
children,
|
||||
showEmailOneTimeAccessButton = false
|
||||
showAlternativeSignInMethodButton = false
|
||||
}: {
|
||||
children: Snippet;
|
||||
showEmailOneTimeAccessButton?: boolean;
|
||||
showAlternativeSignInMethodButton?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<!-- Desktop -->
|
||||
<div class="hidden h-screen items-center text-center lg:flex">
|
||||
<div class="h-full min-w-[650px] p-16 {showEmailOneTimeAccessButton ? 'pb-0' : ''}">
|
||||
{#if browser && !browserSupportsWebAuthn()}
|
||||
<WebAuthnUnsupported />
|
||||
{:else}
|
||||
<div class="h-full min-w-[650px] p-16 {showAlternativeSignInMethodButton ? 'pb-0' : ''}">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex flex-grow flex-col items-center justify-center">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if showEmailOneTimeAccessButton}
|
||||
{#if showAlternativeSignInMethodButton}
|
||||
<div class="mb-4 flex justify-center">
|
||||
<Button
|
||||
href="/login/email?redirect={encodeURIComponent(
|
||||
$page.url.pathname + $page.url.search
|
||||
)}"
|
||||
variant="link"
|
||||
class="text-xs text-muted-foreground"
|
||||
<a
|
||||
href={page.url.pathname == '/login'
|
||||
? '/login/alternative'
|
||||
: `/login/alternative?redirect=${encodeURIComponent(
|
||||
page.url.pathname + page.url.search
|
||||
)}`}
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
Don't have access to your passkey?
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<img
|
||||
src="/api/application-configuration/background-image"
|
||||
@@ -55,25 +48,20 @@
|
||||
>
|
||||
<Card.Root class="mx-3">
|
||||
<Card.CardContent
|
||||
class="px-4 py-10 sm:p-10 {showEmailOneTimeAccessButton ? 'pb-3 sm:pb-3' : ''}"
|
||||
class="px-4 py-10 sm:p-10 {showAlternativeSignInMethodButton ? 'pb-3 sm:pb-3' : ''}"
|
||||
>
|
||||
{#if browser && !browserSupportsWebAuthn()}
|
||||
<WebAuthnUnsupported />
|
||||
{:else}
|
||||
{@render children()}
|
||||
{#if showEmailOneTimeAccessButton}
|
||||
<div class="mt-5">
|
||||
<Button
|
||||
href="/login/email?redirect={encodeURIComponent(
|
||||
$page.url.pathname + $page.url.search
|
||||
)}"
|
||||
variant="link"
|
||||
class="text-xs text-muted-foreground"
|
||||
{#if showAlternativeSignInMethodButton}
|
||||
<a
|
||||
href={page.url.pathname == '/login'
|
||||
? '/login/alternative'
|
||||
: `/login/alternative?redirect=${encodeURIComponent(
|
||||
page.url.pathname + page.url.search
|
||||
)}`}
|
||||
class="text-muted-foreground mt-5 text-xs"
|
||||
>
|
||||
Don't have access to your passkey?
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
</Card.CardContent>
|
||||
</Card.Root>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
@@ -30,8 +30,8 @@
|
||||
async function createOneTimeAccessToken() {
|
||||
try {
|
||||
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
||||
const token = await userService.createOneTimeAccessToken(userId!, expiration);
|
||||
oneTimeLink = `${$page.url.origin}/login/${token}`;
|
||||
const token = await userService.createOneTimeAccessToken(expiration, userId!);
|
||||
oneTimeLink = `${page.url.origin}/lc/${token}`;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
@@ -48,10 +48,9 @@
|
||||
<Dialog.Root open={!!userId} {onOpenChange}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>One Time Link</Dialog.Title>
|
||||
<Dialog.Title>Login Code</Dialog.Title>
|
||||
<Dialog.Description
|
||||
>Use this link to sign in once. This is needed for users who haven't added a passkey yet or
|
||||
have lost it.</Dialog.Description
|
||||
>Create a login code that the user can use to sign in without a passkey once.</Dialog.Description
|
||||
>
|
||||
</Dialog.Header>
|
||||
{#if oneTimeLink === null}
|
||||
@@ -76,11 +75,11 @@
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}>
|
||||
Generate Link
|
||||
Generate Code
|
||||
</Button>
|
||||
{:else}
|
||||
<Label for="one-time-link" class="sr-only">One Time Link</Label>
|
||||
<Input id="one-time-link" value={oneTimeLink} readonly />
|
||||
<Label for="login-code" class="sr-only">Login Code</Label>
|
||||
<Input id="login-code" value={oneTimeLink} readonly />
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -3,11 +3,11 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col justify-center">
|
||||
<div class="mx-auto rounded-2xl bg-muted p-3">
|
||||
<div class="bg-muted mx-auto rounded-2xl p-3">
|
||||
<Logo class="h-10 w-10" />
|
||||
</div>
|
||||
<p class="mt-5 font-playfair text-3xl font-bold sm:text-4xl">Browser unsupported</p>
|
||||
<p class="mt-3 text-muted-foreground">
|
||||
This browser doesn't support passkeys. Please use a browser that supports WebAuthn to sign in.
|
||||
<p class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Browser unsupported</p>
|
||||
<p class="text-muted-foreground mt-3">
|
||||
This browser doesn't support passkeys. Please or use a alternative sign in method.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@ export default class UserService extends APIService {
|
||||
await this.api.put('/users/me/profile-picture', formData);
|
||||
}
|
||||
|
||||
async createOneTimeAccessToken(userId: string, expiresAt: Date) {
|
||||
async createOneTimeAccessToken(expiresAt: Date, userId: string) {
|
||||
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
|
||||
userId,
|
||||
expiresAt
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
{#if client == null}
|
||||
<p>Client not found</p>
|
||||
{:else}
|
||||
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
|
||||
<SignInWrapper showAlternativeSignInMethodButton>
|
||||
<ClientProviderImages {client} {success} error={!!errorMessage} />
|
||||
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
|
||||
{#if errorMessage}
|
||||
|
||||
10
frontend/src/routes/lc/+server.ts
Normal file
10
frontend/src/routes/lc/+server.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
// Alias for /login/alternative/code
|
||||
export function GET({ url }) {
|
||||
let targetPath = '/login/alternative/code';
|
||||
if (url.searchParams.has('redirect')) {
|
||||
targetPath += `?redirect=${encodeURIComponent(url.searchParams.get('redirect')!)}`;
|
||||
}
|
||||
return redirect(307, targetPath);
|
||||
}
|
||||
15
frontend/src/routes/lc/[code]/+server.ts
Normal file
15
frontend/src/routes/lc/[code]/+server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
// Alias for /login/alternative/code?code=...
|
||||
export function GET({ url, params }) {
|
||||
const targetPath = '/login/alternative/code';
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('code', params.code);
|
||||
|
||||
if (url.searchParams.has('redirect')) {
|
||||
searchParams.set('redirect', url.searchParams.get('redirect')!);
|
||||
}
|
||||
|
||||
return redirect(307, `${targetPath}?${searchParams.toString()}`);
|
||||
}
|
||||
@@ -35,19 +35,19 @@
|
||||
<title>Sign In</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
|
||||
<SignInWrapper showAlternativeSignInMethodButton>
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
<h1 class="mt-5 font-playfair text-3xl font-bold sm:text-4xl">
|
||||
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
|
||||
Sign in to {$appConfigStore.appName}
|
||||
</h1>
|
||||
{#if error}
|
||||
<p class="mt-2 text-muted-foreground" in:fade>
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{error}. Please try to sign in again.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-2 text-muted-foreground" in:fade>
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
Authenticate yourself with your passkey to access the admin panel.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store.js';
|
||||
import userStore from '$lib/stores/user-store.js';
|
||||
import { getAxiosErrorMessage } from '$lib/utils/error-util';
|
||||
import { onMount } from 'svelte';
|
||||
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let isLoading = $state(false);
|
||||
let error: string | undefined = $state();
|
||||
const skipPage = data.redirect !== '/settings';
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
async function authenticate() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const user = await userService.exchangeOneTimeAccessToken(data.token);
|
||||
userStore.setUser(user);
|
||||
|
||||
try {
|
||||
goto(data.redirect);
|
||||
} catch (e) {
|
||||
error = 'Invalid redirect URL';
|
||||
}
|
||||
} catch (e) {
|
||||
error = getAxiosErrorMessage(e);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (skipPage) {
|
||||
authenticate();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<SignInWrapper>
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
<h1 class="mt-5 font-playfair text-4xl font-bold">
|
||||
{data.token === 'setup' ? `${$appConfigStore.appName} Setup` : 'One Time Access'}
|
||||
</h1>
|
||||
{#if error}
|
||||
<p class="mt-2 text-muted-foreground">
|
||||
{error}. Please try again.
|
||||
</p>
|
||||
{:else if !skipPage}
|
||||
<p class="mt-2 text-muted-foreground">
|
||||
{#if data.token === 'setup'}
|
||||
You're about to sign in to the initial admin account. Anyone with this link can access the
|
||||
account until a passkey is added. Please set up a passkey as soon as possible to prevent
|
||||
unauthorized access.
|
||||
{:else}
|
||||
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that
|
||||
if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
|
||||
you'll need to request a new link.
|
||||
{/if}
|
||||
</p>
|
||||
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
|
||||
{/if}
|
||||
</SignInWrapper>
|
||||
65
frontend/src/routes/login/alternative/+page.svelte
Normal file
65
frontend/src/routes/login/alternative/+page.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import Logo from '$lib/components/logo.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import { LucideChevronRight, LucideMail, LucideRectangleEllipsis } from 'lucide-svelte';
|
||||
|
||||
const methods = [
|
||||
{
|
||||
icon: LucideRectangleEllipsis,
|
||||
title: 'Login Code',
|
||||
description: 'Enter a login code to sign in.',
|
||||
href: '/login/alternative/code'
|
||||
}
|
||||
];
|
||||
|
||||
if ($appConfigStore.emailOneTimeAccessEnabled) {
|
||||
methods.push({
|
||||
icon: LucideMail,
|
||||
title: 'Email Login',
|
||||
description: 'Request a login code via email.',
|
||||
href: '/login/alternative/email'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign In</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper>
|
||||
<div class="flex h-full flex-col justify-center">
|
||||
<div class="bg-muted mx-auto rounded-2xl p-3">
|
||||
<Logo class="h-10 w-10" />
|
||||
</div>
|
||||
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Alternative Sign In</h1>
|
||||
<p class="text-muted-foreground mt-3">
|
||||
If you dont't have access to your passkey, you can sign in using one of the following methods.
|
||||
</p>
|
||||
<div class="mt-5 flex flex-col gap-3">
|
||||
{#each methods as method}
|
||||
<a href={method.href + page.url.search}>
|
||||
<Card.Root>
|
||||
<Card.Content class="flex items-center justify-between p-4">
|
||||
<div class="flex gap-3">
|
||||
<method.icon class="text-primary h-7 w-7" />
|
||||
<div class="text-start">
|
||||
<h3 class="text-lg font-semibold">{method.title}</h3>
|
||||
<p class="text-muted-foreground text-sm">{method.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost"><LucideChevronRight class="h-5 w-5" /></Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<a class="text-muted-foreground mt-5 text-xs" href={'/login' + page.url.search}
|
||||
>Use your passkey instead?</a
|
||||
>
|
||||
</div>
|
||||
</SignInWrapper>
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url }) => {
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
return {
|
||||
token: params.token,
|
||||
code: url.searchParams.get('code'),
|
||||
redirect: url.searchParams.get('redirect') || '/settings'
|
||||
};
|
||||
};
|
||||
74
frontend/src/routes/login/alternative/code/+page.svelte
Normal file
74
frontend/src/routes/login/alternative/code/+page.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import userStore from '$lib/stores/user-store.js';
|
||||
import { getAxiosErrorMessage } from '$lib/utils/error-util';
|
||||
import { onMount } from 'svelte';
|
||||
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data } = $props();
|
||||
let code = $state(data.code ?? '');
|
||||
let isLoading = $state(false);
|
||||
let error: string | undefined = $state();
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
async function authenticate() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const user = await userService.exchangeOneTimeAccessToken(code);
|
||||
userStore.setUser(user);
|
||||
|
||||
try {
|
||||
goto(data.redirect);
|
||||
} catch (e) {
|
||||
error = 'Invalid redirect URL';
|
||||
}
|
||||
} catch (e) {
|
||||
error = getAxiosErrorMessage(e);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (code) {
|
||||
authenticate();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login Code</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper>
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||
</div>
|
||||
<h1 class="font-playfair mt-5 text-4xl font-bold">Login Code</h1>
|
||||
{#if error}
|
||||
<p class="text-muted-foreground mt-2">
|
||||
{error}. Please try again.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-2">Enter the code you received to sign in.</p>
|
||||
{/if}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
authenticate();
|
||||
}}
|
||||
class="w-full max-w-[450px]"
|
||||
>
|
||||
<Input id="Email" class="mt-7" placeholder="Code" bind:value={code} type="text" />
|
||||
<div class="mt-8 flex justify-stretch gap-2">
|
||||
<Button variant="secondary" class="w-full" href={"/login/alternative" + page.url.search}>Go back</Button>
|
||||
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SignInWrapper>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte';
|
||||
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
@@ -27,16 +28,16 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Email One Time Access</title>
|
||||
<title>Email Login</title>
|
||||
</svelte:head>
|
||||
|
||||
<SignInWrapper>
|
||||
<div class="flex justify-center">
|
||||
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
|
||||
</div>
|
||||
<h1 class="mt-5 font-playfair text-3xl font-bold sm:text-4xl">Email One Time Access</h1>
|
||||
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Email Login</h1>
|
||||
{#if error}
|
||||
<p class="mt-2 text-muted-foreground" in:fade>
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{error}. Please try again.
|
||||
</p>
|
||||
<div class="mt-10 flex w-full justify-stretch gap-2">
|
||||
@@ -44,17 +45,25 @@
|
||||
<Button class="w-full" onclick={() => (error = undefined)}>Try again</Button>
|
||||
</div>
|
||||
{:else if success}
|
||||
<p class="mt-2 text-muted-foreground" in:fade>
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
An email has been sent to the provided email, if it exists in the system.
|
||||
</p>
|
||||
<div class="mt-8 flex w-full justify-stretch gap-2">
|
||||
<Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search}
|
||||
>Go back</Button
|
||||
>
|
||||
<Button class="w-full" href={'/login/alternative/code' + page.url.search}>Enter code</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={requestEmail}>
|
||||
<p class="mt-2 text-muted-foreground" in:fade>
|
||||
Enter your email to receive an email with a one time access link.
|
||||
<form onsubmit={requestEmail} class="w-full max-w-[450px]">
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
Enter your email address to receive an email with a login code.
|
||||
</p>
|
||||
<Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} />
|
||||
<div class="mt-8 flex justify-stretch gap-2">
|
||||
<Button variant="secondary" class="w-full" href="/">Go back</Button>
|
||||
<Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search}
|
||||
>Go back</Button
|
||||
>
|
||||
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -11,15 +11,17 @@
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { LucideAlertTriangle } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import AccountForm from './account-form.svelte';
|
||||
import PasskeyList from './passkey-list.svelte';
|
||||
import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte';
|
||||
import AccountForm from './account-form.svelte';
|
||||
import LoginCodeModal from './login-code-modal.svelte';
|
||||
import PasskeyList from './passkey-list.svelte';
|
||||
import RenamePasskeyModal from './rename-passkey-modal.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let account = $state(data.account);
|
||||
let passkeys = $state(data.passkeys);
|
||||
let passkeyToRename: Passkey | null = $state(null);
|
||||
let showLoginCodeModal: boolean = $state(false);
|
||||
|
||||
const userService = new UserService();
|
||||
const webauthnService = new WebAuthnService();
|
||||
@@ -96,7 +98,11 @@
|
||||
|
||||
<Card.Root>
|
||||
<Card.Content class="pt-6">
|
||||
<ProfilePictureSettings userId="me" isLdapUser={!!account.ldapId} callback={updateProfilePicture} />
|
||||
<ProfilePictureSettings
|
||||
userId="me"
|
||||
isLdapUser={!!account.ldapId}
|
||||
callback={updateProfilePicture}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -109,7 +115,7 @@
|
||||
Manage your passkeys that you can use to authenticate yourself.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button size="sm" on:click={createPasskey}>Add Passkey</Button>
|
||||
<Button size="sm" class="ml-3" on:click={createPasskey}>Add Passkey</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
{#if passkeys.length != 0}
|
||||
@@ -118,7 +124,23 @@
|
||||
</Card.Content>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title>Login Code</Card.Title>
|
||||
<Card.Description class="mt-1">
|
||||
Create a one-time login code to sign in from a different device without a passkey.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button size="sm" class="ml-auto" on:click={() => (showLoginCodeModal = true)}>Create</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
|
||||
<RenamePasskeyModal
|
||||
bind:passkey={passkeyToRename}
|
||||
callback={async () => (passkeys = await webauthnService.listCredentials())}
|
||||
/>
|
||||
<LoginCodeModal bind:show={showLoginCodeModal} />
|
||||
|
||||
62
frontend/src/routes/settings/account/login-code-modal.svelte
Normal file
62
frontend/src/routes/settings/account/login-code-modal.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
|
||||
let {
|
||||
show = $bindable()
|
||||
}: {
|
||||
show: boolean;
|
||||
} = $props();
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
let code: string | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (show) {
|
||||
const expiration = new Date(Date.now() + 15 * 60 * 1000);
|
||||
userService
|
||||
.createOneTimeAccessToken(expiration, 'me')
|
||||
.then((c) => (code = c))
|
||||
.catch((e) => axiosErrorToast(e));
|
||||
}
|
||||
});
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
code = null;
|
||||
show = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={!!code} {onOpenChange}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Login Code</Dialog.Title>
|
||||
<Dialog.Description
|
||||
>Sign in using the following code. The code will expire in 15 minutes.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<CopyToClipboard value={code!}>
|
||||
<p class="text-3xl font-semibold">{code}</p>
|
||||
</CopyToClipboard>
|
||||
<div class="text-muted-foreground flex items-center justify-center gap-3">
|
||||
<Separator />
|
||||
<p class="text-nowrap text-xs">or visit</p>
|
||||
<Separator />
|
||||
</div>
|
||||
<div>
|
||||
<CopyToClipboard value={page.url.origin + '/lc/' + code!}>
|
||||
<p data-testId="login-code-link">{page.url.origin + '/lc/' + code!}</p>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -135,9 +135,9 @@
|
||||
bind:checked={$inputs.emailLoginNotificationEnabled.value}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="email-one-time-access"
|
||||
label="Email One Time Access"
|
||||
description="Allows users to sign in with a link sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry."
|
||||
id="email-login"
|
||||
label="Email Login"
|
||||
description="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."
|
||||
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
|
||||
import Ellipsis from 'lucide-svelte/icons/ellipsis';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import OneTimeLinkModal from './one-time-link-modal.svelte';
|
||||
import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte';
|
||||
|
||||
let {
|
||||
users = $bindable(),
|
||||
@@ -82,7 +82,7 @@
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)}
|
||||
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
|
||||
><LucideLink class="mr-2 h-4 w-4" />Login Code</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)}
|
||||
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
|
||||
|
||||
@@ -69,3 +69,20 @@ test('Delete passkey from account', async ({ page }) => {
|
||||
|
||||
await expect(page.getByRole('status')).toHaveText('Passkey deleted successfully');
|
||||
});
|
||||
|
||||
test('Generate own one time access token as non admin', async ({ page, context }) => {
|
||||
await context.clearCookies();
|
||||
await page.goto('/login');
|
||||
await (await passkeyUtil.init(page)).addPasskey('craig');
|
||||
|
||||
await page.getByRole('button', { name: 'Authenticate' }).click();
|
||||
await page.waitForURL('/settings/account');
|
||||
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
const link = await page.getByTestId('login-code-link').textContent();
|
||||
|
||||
await context.clearCookies();
|
||||
|
||||
await page.goto(link!);
|
||||
await page.waitForURL('/settings/account');
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ 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 One Time Access').click();
|
||||
await page.getByLabel('Email Login', { exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||
|
||||
@@ -46,7 +46,7 @@ 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 One Time Access')).toBeChecked();
|
||||
await expect(page.getByLabel('Email Login', { exact: true })).toBeChecked();
|
||||
});
|
||||
|
||||
test('Update LDAP configuration', async ({ page }) => {
|
||||
|
||||
@@ -1,22 +1,47 @@
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { oneTimeAccessTokens } from './data';
|
||||
import { cleanupBackend } from './utils/cleanup.util';
|
||||
|
||||
test.beforeEach(cleanupBackend);
|
||||
|
||||
// Disable authentication for these tests
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('Sign in with one time access token', async ({ page }) => {
|
||||
test('Sign in with login code', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
|
||||
await page.goto(`/login/${token.token}`);
|
||||
await page.goto(`/lc/${token.token}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.waitForURL('/settings/account');
|
||||
});
|
||||
|
||||
test('Sign in with expired one time access token fails', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
|
||||
await page.goto(`/login/${token.token}`);
|
||||
test('Sign in with login code entered manually', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
|
||||
await page.goto('/lc');
|
||||
|
||||
await page.getByPlaceholder('Code').first().fill(token.token);
|
||||
|
||||
await page.getByText('Submit').first().click();
|
||||
|
||||
await page.waitForURL('/settings/account');
|
||||
});
|
||||
|
||||
test('Sign in with expired login code fails', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
|
||||
await page.goto(`/lc/${token.token}`);
|
||||
|
||||
await expect(page.getByRole('paragraph')).toHaveText(
|
||||
'Token is invalid or expired. Please try again.'
|
||||
);
|
||||
});
|
||||
|
||||
test('Sign in with login code entered manually fails', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
|
||||
await page.goto('/lc');
|
||||
|
||||
await page.getByPlaceholder('Code').first().fill(token.token);
|
||||
|
||||
await page.getByText('Submit').first().click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('paragraph')).toHaveText(
|
||||
'Token is invalid or expired. Please try again.'
|
||||
);
|
||||
|
||||
@@ -58,14 +58,14 @@ test('Create one time access token', async ({ page }) => {
|
||||
.getByRole('button')
|
||||
.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'One-time link' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Login Code' }).click();
|
||||
|
||||
await page.getByLabel('One Time Link').getByRole('combobox').click();
|
||||
await page.getByLabel('Login Code').getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: '12 hours' }).click();
|
||||
await page.getByRole('button', { name: 'Generate Link' }).click();
|
||||
await page.getByRole('button', { name: 'Generate Code' }).click();
|
||||
|
||||
await expect(page.getByRole('textbox', { name: 'One Time Link' })).toHaveValue(
|
||||
/http:\/\/localhost\/login\/.*/
|
||||
await expect(page.getByRole('textbox', { name: 'Login Code' })).toHaveValue(
|
||||
/http:\/\/localhost\/lc\/.*/
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ fi
|
||||
echo "================================================="
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "A one-time access token valid for 1 hour has been created for \"$USER_IDENTIFIER\"."
|
||||
echo "Use the following URL to sign in once: ${PUBLIC_APP_URL:=https://<your-pocket-id-domain>}/login/$SECRET_TOKEN"
|
||||
echo "Use the following URL to sign in once: ${PUBLIC_APP_URL:=https://<your-pocket-id-domain>}/lc/$SECRET_TOKEN"
|
||||
else
|
||||
echo "Error creating access token."
|
||||
exit 1
|
||||
|
||||
Reference in New Issue
Block a user