mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-18 01:11:26 +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 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", acc.listAppConfigHandler)
|
||||||
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
|
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/logo", acc.getLogoHandler)
|
||||||
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
|
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/:id/profile-picture", jwtAuthMiddleware.Add(true), uc.updateUserProfilePictureHandler)
|
||||||
group.PUT("/users/me/profile-picture", jwtAuthMiddleware.Add(false), uc.updateCurrentUserProfilePictureHandler)
|
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/: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-token/setup", uc.getSetupAccessTokenHandler)
|
||||||
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
|
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)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
|
||||||
var input dto.OneTimeAccessTokenCreateDto
|
var input dto.OneTimeAccessTokenCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if own {
|
||||||
|
input.UserID = c.GetString("userID")
|
||||||
|
}
|
||||||
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -251,6 +255,14 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, gin.H{"token": token})
|
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) {
|
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
|
||||||
var input dto.OneTimeAccessEmailDto
|
var input dto.OneTimeAccessEmailDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type UserCreateDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessTokenCreateDto struct {
|
type OneTimeAccessTokenCreateDto struct {
|
||||||
UserID string `json:"userId" binding:"required"`
|
UserID string `json:"userId"`
|
||||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
|||||||
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
|
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
|
||||||
Path: "one-time-access",
|
Path: "one-time-access",
|
||||||
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
|
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
|
||||||
return "One time access"
|
return "Login Code"
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,9 @@ type NewLoginTemplateData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessTemplateData = struct {
|
type OneTimeAccessTemplateData = struct {
|
||||||
Link string
|
Code string
|
||||||
|
LoginLink string
|
||||||
|
LoginLinkWithCode string
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is list of all template paths used for preloading templates
|
// 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 {
|
func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
|
||||||
|
isDisabled := s.appConfigService.DbConfig.EmailOneTimeAccessEnabled.Value != "true"
|
||||||
|
if isDisabled {
|
||||||
|
return &common.OneTimeAccessDisabledError{}
|
||||||
|
}
|
||||||
|
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
|
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
|
||||||
// Do not return error if user not found to prevent email enumeration
|
// 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 {
|
if err != nil {
|
||||||
return err
|
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
|
// Add redirect path to the link
|
||||||
if strings.HasPrefix(redirectPath, "/") {
|
if strings.HasPrefix(redirectPath, "/") {
|
||||||
encodedRedirectPath := url.QueryEscape(redirectPath)
|
encodedRedirectPath := url.QueryEscape(redirectPath)
|
||||||
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
|
linkWithCode = fmt.Sprintf("%s?redirect=%s", linkWithCode, encodedRedirectPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -225,7 +231,9 @@ func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath strin
|
|||||||
Name: user.Username,
|
Name: user.Username,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
||||||
Link: link,
|
Code: oneTimeAccessToken,
|
||||||
|
LoginLink: link,
|
||||||
|
LoginLinkWithCode: linkWithCode,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
|
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) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2>One-Time Access</h2>
|
<h2>Login Code</h2>
|
||||||
<p class="message">
|
<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>
|
</p>
|
||||||
<div class="button-container">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{{ end -}}
|
{{ end -}}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
{{ define "base" -}}
|
{{ 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 -}}
|
{{ end -}}
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "0.35.2",
|
"version": "0.35.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "0.35.2",
|
"version": "0.35.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^13.1.0",
|
"@simplewebauthn/browser": "^13.1.0",
|
||||||
"@tailwindcss/vite": "^4.0.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 }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
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 isPublicPath = ['/authorize', '/health'].includes(event.url.pathname);
|
||||||
const isAdminPath = event.url.pathname.startsWith('/settings/admin');
|
const isAdminPath = event.url.pathname.startsWith('/settings/admin');
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}>
|
<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}>
|
<Tooltip.Content onclick={copyToClipboard}>
|
||||||
{#if copied}
|
{#if copied}
|
||||||
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
|
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
|
||||||
|
|||||||
@@ -1,46 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { page } from '$app/state';
|
||||||
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
|
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { Button } from './ui/button';
|
|
||||||
import * as Card from './ui/card';
|
import * as Card from './ui/card';
|
||||||
import WebAuthnUnsupported from './web-authn-unsupported.svelte';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
children,
|
children,
|
||||||
showEmailOneTimeAccessButton = false
|
showAlternativeSignInMethodButton = false
|
||||||
}: {
|
}: {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
showEmailOneTimeAccessButton?: boolean;
|
showAlternativeSignInMethodButton?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Desktop -->
|
<!-- Desktop -->
|
||||||
<div class="hidden h-screen items-center text-center lg:flex">
|
<div class="hidden h-screen items-center text-center lg:flex">
|
||||||
<div class="h-full min-w-[650px] p-16 {showEmailOneTimeAccessButton ? 'pb-0' : ''}">
|
<div class="h-full min-w-[650px] p-16 {showAlternativeSignInMethodButton ? 'pb-0' : ''}">
|
||||||
{#if browser && !browserSupportsWebAuthn()}
|
<div class="flex h-full flex-col">
|
||||||
<WebAuthnUnsupported />
|
<div class="flex flex-grow flex-col items-center justify-center">
|
||||||
{:else}
|
{@render children()}
|
||||||
<div class="flex h-full flex-col">
|
|
||||||
<div class="flex flex-grow flex-col items-center justify-center">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
{#if showEmailOneTimeAccessButton}
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
Don't have access to your passkey?
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if showAlternativeSignInMethodButton}
|
||||||
|
<div class="mb-4 flex justify-center">
|
||||||
|
<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?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
src="/api/application-configuration/background-image"
|
src="/api/application-configuration/background-image"
|
||||||
@@ -55,25 +48,20 @@
|
|||||||
>
|
>
|
||||||
<Card.Root class="mx-3">
|
<Card.Root class="mx-3">
|
||||||
<Card.CardContent
|
<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()}
|
{@render children()}
|
||||||
<WebAuthnUnsupported />
|
{#if showAlternativeSignInMethodButton}
|
||||||
{:else}
|
<a
|
||||||
{@render children()}
|
href={page.url.pathname == '/login'
|
||||||
{#if showEmailOneTimeAccessButton}
|
? '/login/alternative'
|
||||||
<div class="mt-5">
|
: `/login/alternative?redirect=${encodeURIComponent(
|
||||||
<Button
|
page.url.pathname + page.url.search
|
||||||
href="/login/email?redirect={encodeURIComponent(
|
)}`}
|
||||||
$page.url.pathname + $page.url.search
|
class="text-muted-foreground mt-5 text-xs"
|
||||||
)}"
|
>
|
||||||
variant="link"
|
Don't have access to your passkey?
|
||||||
class="text-xs text-muted-foreground"
|
</a>
|
||||||
>
|
|
||||||
Don't have access to your passkey?
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</Card.CardContent>
|
</Card.CardContent>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import Input from '$lib/components/ui/input/input.svelte';
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
async function createOneTimeAccessToken() {
|
async function createOneTimeAccessToken() {
|
||||||
try {
|
try {
|
||||||
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
||||||
const token = await userService.createOneTimeAccessToken(userId!, expiration);
|
const token = await userService.createOneTimeAccessToken(expiration, userId!);
|
||||||
oneTimeLink = `${$page.url.origin}/login/${token}`;
|
oneTimeLink = `${page.url.origin}/lc/${token}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
}
|
}
|
||||||
@@ -48,10 +48,9 @@
|
|||||||
<Dialog.Root open={!!userId} {onOpenChange}>
|
<Dialog.Root open={!!userId} {onOpenChange}>
|
||||||
<Dialog.Content class="max-w-md">
|
<Dialog.Content class="max-w-md">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>One Time Link</Dialog.Title>
|
<Dialog.Title>Login Code</Dialog.Title>
|
||||||
<Dialog.Description
|
<Dialog.Description
|
||||||
>Use this link to sign in once. This is needed for users who haven't added a passkey yet or
|
>Create a login code that the user can use to sign in without a passkey once.</Dialog.Description
|
||||||
have lost it.</Dialog.Description
|
|
||||||
>
|
>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
{#if oneTimeLink === null}
|
{#if oneTimeLink === null}
|
||||||
@@ -76,11 +75,11 @@
|
|||||||
</Select.Root>
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
<Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}>
|
<Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}>
|
||||||
Generate Link
|
Generate Code
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Label for="one-time-link" class="sr-only">One Time Link</Label>
|
<Label for="login-code" class="sr-only">Login Code</Label>
|
||||||
<Input id="one-time-link" value={oneTimeLink} readonly />
|
<Input id="login-code" value={oneTimeLink} readonly />
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col justify-center">
|
<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" />
|
<Logo class="h-10 w-10" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-5 font-playfair text-3xl font-bold sm:text-4xl">Browser unsupported</p>
|
<p class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Browser unsupported</p>
|
||||||
<p class="mt-3 text-muted-foreground">
|
<p class="text-muted-foreground mt-3">
|
||||||
This browser doesn't support passkeys. Please use a browser that supports WebAuthn to sign in.
|
This browser doesn't support passkeys. Please or use a alternative sign in method.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default class UserService extends APIService {
|
|||||||
await this.api.put('/users/me/profile-picture', formData);
|
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`, {
|
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
|
||||||
userId,
|
userId,
|
||||||
expiresAt
|
expiresAt
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
{#if client == null}
|
{#if client == null}
|
||||||
<p>Client not found</p>
|
<p>Client not found</p>
|
||||||
{:else}
|
{:else}
|
||||||
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
|
<SignInWrapper showAlternativeSignInMethodButton>
|
||||||
<ClientProviderImages {client} {success} error={!!errorMessage} />
|
<ClientProviderImages {client} {success} error={!!errorMessage} />
|
||||||
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
|
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
|
||||||
{#if errorMessage}
|
{#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>
|
<title>Sign In</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
|
<SignInWrapper showAlternativeSignInMethodButton>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<LoginLogoErrorSuccessIndicator error={!!error} />
|
<LoginLogoErrorSuccessIndicator error={!!error} />
|
||||||
</div>
|
</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}
|
Sign in to {$appConfigStore.appName}
|
||||||
</h1>
|
</h1>
|
||||||
{#if error}
|
{#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.
|
{error}. Please try to sign in again.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{: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.
|
Authenticate yourself with your passkey to access the admin panel.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, url }) => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
return {
|
return {
|
||||||
token: params.token,
|
code: url.searchParams.get('code'),
|
||||||
redirect: url.searchParams.get('redirect') || '/settings'
|
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">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Input from '$lib/components/ui/input/input.svelte';
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import { fade } from 'svelte/transition';
|
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();
|
const { data } = $props();
|
||||||
|
|
||||||
@@ -27,16 +28,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Email One Time Access</title>
|
<title>Email Login</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<SignInWrapper>
|
<SignInWrapper>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
|
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
|
||||||
</div>
|
</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}
|
{#if error}
|
||||||
<p class="mt-2 text-muted-foreground" in:fade>
|
<p class="text-muted-foreground mt-2" in:fade>
|
||||||
{error}. Please try again.
|
{error}. Please try again.
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-10 flex w-full justify-stretch gap-2">
|
<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>
|
<Button class="w-full" onclick={() => (error = undefined)}>Try again</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if success}
|
{: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.
|
An email has been sent to the provided email, if it exists in the system.
|
||||||
</p>
|
</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}
|
{:else}
|
||||||
<form onsubmit={requestEmail}>
|
<form onsubmit={requestEmail} class="w-full max-w-[450px]">
|
||||||
<p class="mt-2 text-muted-foreground" in:fade>
|
<p class="text-muted-foreground mt-2" in:fade>
|
||||||
Enter your email to receive an email with a one time access link.
|
Enter your email address to receive an email with a login code.
|
||||||
</p>
|
</p>
|
||||||
<Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} />
|
<Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} />
|
||||||
<div class="mt-8 flex justify-stretch gap-2">
|
<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>
|
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -11,15 +11,17 @@
|
|||||||
import { startRegistration } from '@simplewebauthn/browser';
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
import { LucideAlertTriangle } from 'lucide-svelte';
|
import { LucideAlertTriangle } from 'lucide-svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
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 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';
|
import RenamePasskeyModal from './rename-passkey-modal.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let account = $state(data.account);
|
let account = $state(data.account);
|
||||||
let passkeys = $state(data.passkeys);
|
let passkeys = $state(data.passkeys);
|
||||||
let passkeyToRename: Passkey | null = $state(null);
|
let passkeyToRename: Passkey | null = $state(null);
|
||||||
|
let showLoginCodeModal: boolean = $state(false);
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
const webauthnService = new WebAuthnService();
|
const webauthnService = new WebAuthnService();
|
||||||
@@ -96,7 +98,11 @@
|
|||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Content class="pt-6">
|
<Card.Content class="pt-6">
|
||||||
<ProfilePictureSettings userId="me" isLdapUser={!!account.ldapId} callback={updateProfilePicture} />
|
<ProfilePictureSettings
|
||||||
|
userId="me"
|
||||||
|
isLdapUser={!!account.ldapId}
|
||||||
|
callback={updateProfilePicture}
|
||||||
|
/>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
@@ -109,7 +115,7 @@
|
|||||||
Manage your passkeys that you can use to authenticate yourself.
|
Manage your passkeys that you can use to authenticate yourself.
|
||||||
</Card.Description>
|
</Card.Description>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" on:click={createPasskey}>Add Passkey</Button>
|
<Button size="sm" class="ml-3" on:click={createPasskey}>Add Passkey</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
{#if passkeys.length != 0}
|
{#if passkeys.length != 0}
|
||||||
@@ -118,7 +124,23 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Root>
|
</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
|
<RenamePasskeyModal
|
||||||
bind:passkey={passkeyToRename}
|
bind:passkey={passkeyToRename}
|
||||||
callback={async () => (passkeys = await webauthnService.listCredentials())}
|
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}
|
bind:checked={$inputs.emailLoginNotificationEnabled.value}
|
||||||
/>
|
/>
|
||||||
<CheckboxWithLabel
|
<CheckboxWithLabel
|
||||||
id="email-one-time-access"
|
id="email-login"
|
||||||
label="Email One Time Access"
|
label="Email Login"
|
||||||
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."
|
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}
|
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
|
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
|
||||||
import Ellipsis from 'lucide-svelte/icons/ellipsis';
|
import Ellipsis from 'lucide-svelte/icons/ellipsis';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import OneTimeLinkModal from './one-time-link-modal.svelte';
|
import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
users = $bindable(),
|
users = $bindable(),
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content align="end">
|
<DropdownMenu.Content align="end">
|
||||||
<DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)}
|
<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}`)}
|
<DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)}
|
||||||
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
|
><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');
|
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 Password').fill('password');
|
||||||
await page.getByLabel('SMTP From').fill('test@gmail.com');
|
await page.getByLabel('SMTP From').fill('test@gmail.com');
|
||||||
await page.getByLabel('Email Login Notification').click();
|
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();
|
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 Password')).toHaveValue('password');
|
||||||
await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com');
|
await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com');
|
||||||
await expect(page.getByLabel('Email Login Notification')).toBeChecked();
|
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 }) => {
|
test('Update LDAP configuration', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,22 +1,47 @@
|
|||||||
import test, { expect } from '@playwright/test';
|
import test, { expect } from '@playwright/test';
|
||||||
import { oneTimeAccessTokens } from './data';
|
import { oneTimeAccessTokens } from './data';
|
||||||
|
import { cleanupBackend } from './utils/cleanup.util';
|
||||||
|
|
||||||
|
test.beforeEach(cleanupBackend);
|
||||||
|
|
||||||
// Disable authentication for these tests
|
// Disable authentication for these tests
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
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];
|
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');
|
await page.waitForURL('/settings/account');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Sign in with expired one time access token fails', async ({ page }) => {
|
test('Sign in with login code entered manually', async ({ page }) => {
|
||||||
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
|
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
|
||||||
await page.goto(`/login/${token.token}`);
|
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(
|
await expect(page.getByRole('paragraph')).toHaveText(
|
||||||
'Token is invalid or expired. Please try again.'
|
'Token is invalid or expired. Please try again.'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ test('Create one time access token', async ({ page }) => {
|
|||||||
.getByRole('button')
|
.getByRole('button')
|
||||||
.click();
|
.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('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(
|
await expect(page.getByRole('textbox', { name: 'Login Code' })).toHaveValue(
|
||||||
/http:\/\/localhost\/login\/.*/
|
/http:\/\/localhost\/lc\/.*/
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ fi
|
|||||||
echo "================================================="
|
echo "================================================="
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
echo "A one-time access token valid for 1 hour has been created for \"$USER_IDENTIFIER\"."
|
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
|
else
|
||||||
echo "Error creating access token."
|
echo "Error creating access token."
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Reference in New Issue
Block a user