Compare commits

...

16 Commits

Author SHA1 Message Date
Elias Schneider
ee885fbff5 release: 0.22.0 2025-01-01 23:13:53 +01:00
Elias Schneider
333a1a18d5 fix: make user validation consistent between pages 2025-01-01 23:13:16 +01:00
Elias Schneider
1ff20caa3c fix: allow first and last name of user to be between 1 and 50 characters 2025-01-01 22:48:51 +01:00
Elias Schneider
f6f2736bba fix: hash in callback url is incorrectly appended 2025-01-01 22:46:59 +01:00
Elias Schneider
993330d932 Merge remote-tracking branch 'origin/main' 2025-01-01 22:46:29 +01:00
Jan-Philipp Fischer
204313aacf docs: add "groups" scope to the oauth2-proxy sample configuration (#85) 2024-12-31 11:31:39 +01:00
Elias Schneider
0729ce9e1a fix: passkey can't be added if PUBLIC_APP_URL includes a port 2024-12-31 10:42:54 +01:00
Elias Schneider
2d0bd8dcbf feat: add warning if passkeys missing 2024-12-23 09:59:12 +01:00
Elias Schneider
ff75322e7d docs: improve text in README 2024-12-20 08:20:40 +01:00
Elias Schneider
daced661c4 release: 0.21.0 2024-12-17 19:58:55 +01:00
Elias Schneider
0716c38fb8 feat: improve error state design for login page 2024-12-17 19:36:47 +01:00
Elias Schneider
789d9394a5 fix: OIDC client logo gets removed if other properties get updated 2024-12-17 19:00:33 +01:00
Elias Schneider
aeda512cb7 release: 0.20.1 2024-12-13 09:12:37 +01:00
Elias Schneider
5480ab0f18 tests: add e2e test for one time access tokens 2024-12-13 09:03:52 +01:00
Elias Schneider
bad901ea2b fix: wrong date time datatype used for read operations with Postgres 2024-12-13 08:43:46 +01:00
Elias Schneider
34e35193f9 fix: create-one-time-access-token.sh script not compatible with postgres 2024-12-12 23:03:07 +01:00
30 changed files with 331 additions and 67 deletions

View File

@@ -1 +1 @@
0.20.0
0.22.0

View File

@@ -1,3 +1,38 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.21.0...v) (2025-01-01)
### Features
* add warning if passkeys missing ([2d0bd8d](https://github.com/stonith404/pocket-id/commit/2d0bd8dcbfb73650b7829cb66f40decb284bd73b))
### Bug Fixes
* allow first and last name of user to be between 1 and 50 characters ([1ff20ca](https://github.com/stonith404/pocket-id/commit/1ff20caa3ccd651f9fb30f958ffb807dfbbcbd8a))
* hash in callback url is incorrectly appended ([f6f2736](https://github.com/stonith404/pocket-id/commit/f6f2736bba65eee017f2d8cdaa70621574092869))
* make user validation consistent between pages ([333a1a1](https://github.com/stonith404/pocket-id/commit/333a1a18d59f675111f4ed106fa5614ef563c6f4))
* passkey can't be added if `PUBLIC_APP_URL` includes a port ([0729ce9](https://github.com/stonith404/pocket-id/commit/0729ce9e1a8dab9912900a01dcd0fbd892718a1a))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.1...v) (2024-12-17)
### Features
* improve error state design for login page ([0716c38](https://github.com/stonith404/pocket-id/commit/0716c38fb8ce7fa719c7fe0df750bdb213786c21))
### Bug Fixes
* OIDC client logo gets removed if other properties get updated ([789d939](https://github.com/stonith404/pocket-id/commit/789d9394a533831e7e2fb8dc3f6b338787336ad8))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.0...v) (2024-12-13)
### Bug Fixes
* `create-one-time-access-token.sh` script not compatible with postgres ([34e3519](https://github.com/stonith404/pocket-id/commit/34e35193f9f3813f6248e60f15080d753e8da7ae))
* wrong date time datatype used for read operations with Postgres ([bad901e](https://github.com/stonith404/pocket-id/commit/bad901ea2b661aadd286e5e4bed317e73bd8a70d))
## [](https://github.com/stonith404/pocket-id/compare/v0.19.0...v) (2024-12-12)

View File

@@ -17,7 +17,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
### Before you start
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) which requires a secure context.
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API).
### Installation with Docker (recommended)
@@ -78,14 +78,14 @@ Required tools:
# Optional: Start Caddy (You can use any other reverse proxy)
cd ..
pm2 start --name pocket-id-caddy caddy -- run --config Caddyfile
pm2 start --name pocket-id-caddy caddy -- run --config reverse-proxy/Caddyfile
```
You can now sign in with the admin account on `http://localhost/login/setup`.
### Nginx Reverse Proxy
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
To use Nginx as a reverse proxy for Pocket ID, update the configuration to increase the header buffer size. This adjustment is necessary because SvelteKit generates larger headers, which may exceed the default buffer limits.
```nginx
proxy_busy_buffers_size 512k;
@@ -95,7 +95,7 @@ proxy_buffer_size 256k;
## Proxy Services with Pocket ID
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
As the goal of Pocket ID is to stay simple, it doesn't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
See the [guide](docs/proxy-services.md) for more information.
@@ -136,7 +136,7 @@ docker compose up -d
# Optional: Start Caddy (You can use any other reverse proxy)
cd ..
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
pm2 start caddy --name pocket-id-caddy -- run --config reverse-proxy/Caddyfile
```
## Environment variables

View File

@@ -13,10 +13,10 @@ type UserDto struct {
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=3,max=20"`
Username string `json:"username" binding:"required,username,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=3,max=30"`
LastName string `json:"lastName" binding:"required,min=3,max=30"`
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"required,min=1,max=50"`
IsAdmin bool `json:"isAdmin"`
}

View File

@@ -4,6 +4,7 @@ import (
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"github.com/stonith404/pocket-id/backend/internal/model"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"log"
"time"
@@ -29,22 +30,22 @@ type Jobs struct {
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *Jobs) clearWebauthnSessions() error {
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", time.Now().Unix()).Error
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *Jobs) clearOneTimeAccessTokens() error {
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", time.Now().Unix()).Error
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *Jobs) clearOidcAuthorizationCodes() error {
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", time.Now().Unix()).Error
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *Jobs) clearAuditLogs() error {
return j.db.Delete(&model.AuditLog{}, "created_at < ?", time.Now().AddDate(0, 0, -90).Unix()).Error
return j.db.Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error
}
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {

View File

@@ -6,7 +6,7 @@ import (
"time"
)
// DateTime custom type for time.Time to store date as unix timestamp in the database
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
type DateTime time.Time
func (date *DateTime) Scan(value interface{}) (err error) {

View File

@@ -12,7 +12,6 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"log"
"math/big"
"os"
@@ -96,7 +95,7 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{utils.GetHostFromURL(common.EnvConfig.AppURL)},
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL},
},
IsAdmin: user.IsAdmin,
}
@@ -125,7 +124,7 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaim
return nil, errors.New("can't parse claims")
}
if !slices.Contains(claims.Audience, utils.GetHostFromURL(common.EnvConfig.AppURL)) {
if !slices.Contains(claims.Audience, common.EnvConfig.AppURL) {
return nil, errors.New("audience doesn't match")
}
return claims, nil

View File

@@ -57,6 +57,29 @@ func (s *TestService) SeedDatabase() error {
}
}
oneTimeAccessTokens := []model.OneTimeAccessToken{{
Base: model.Base{
ID: "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
},
Token: "HPe6k6uiDRRVuAQV",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
},
{
Base: model.Base{
ID: "d3afae24-fe2d-4a98-abec-cf0b8525096a",
},
Token: "YCGDtftvsvYWiXd0",
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Second)), // expired
UserID: users[0].ID,
},
}
for _, token := range oneTimeAccessTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
userGroups := []model.UserGroup{
{
Base: model.Base{

View File

@@ -112,7 +112,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
var oneTimeAccessToken model.OneTimeAccessToken
if err := s.db.Where("token = ? AND expires_at > ?", token, time.Now().Unix()).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}

View File

@@ -23,7 +23,7 @@ type WebAuthnService struct {
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
webauthnConfig := &webauthn.Config{
RPDisplayName: appConfigService.DbConfig.AppName.Value,
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
RPOrigins: []string{common.EnvConfig.AppURL},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{

View File

@@ -29,12 +29,12 @@ func GenerateRandomAlphanumericString(length int) (string, error) {
return string(result), nil
}
func GetHostFromURL(rawURL string) string {
func GetHostnameFromURL(rawURL string) string {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return ""
}
return parsedURL.Host
return parsedURL.Hostname()
}
// StringPointer creates a string pointer from a string value

View File

@@ -45,7 +45,7 @@ upstreams="http://<service-to-be-proxied>:<port>"
# Additional Configuration
provider="oidc"
scope = "openid email profile"
scope = "openid email profile groups"
# If you are using a reverse proxy in front of OAuth2 Proxy
reverse_proxy = true

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "0.20.0",
"version": "0.22.0",
"private": true,
"scripts": {
"dev": "vite dev --port 3000",

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div class={cn("text-sm [&_p]:leading-relaxed", className)} {...$$restProps}>
<slot />
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { HeadingLevel } from "./index.js";
import { cn } from "$lib/utils/style.js";
type $$Props = HTMLAttributes<HTMLHeadingElement> & {
level?: HeadingLevel;
};
let className: $$Props["class"] = undefined;
export let level: $$Props["level"] = "h5";
export { className as class };
</script>
<svelte:element
this={level}
class={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...$$restProps}
>
<slot />
</svelte:element>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { type Variant, alertVariants } from "./index.js";
import { cn } from "$lib/utils/style.js";
type $$Props = HTMLAttributes<HTMLDivElement> & {
variant?: Variant;
};
let className: $$Props["class"] = undefined;
export let variant: $$Props["variant"] = "default";
export { className as class };
</script>
<div class={cn(alertVariants({ variant }), className)} {...$$restProps} role="alert">
<slot />
</div>

View File

@@ -0,0 +1,35 @@
import { type VariantProps, tv } from 'tailwind-variants';
import Description from './alert-description.svelte';
import Title from './alert-title.svelte';
import Root from './alert.svelte';
export const alertVariants = tv({
base: '[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4',
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive text-destructive dark:border-destructive [&>svg]:text-destructive',
warning:
'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100 [&>svg]:text-amber-900 dark:[&>svg]:text-amber-100'
}
},
defaultVariants: {
variant: 'default'
}
});
export type Variant = VariantProps<typeof alertVariants>['variant'];
export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
export {
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
Description,
Root,
Title
};

View File

@@ -10,7 +10,7 @@ export type OidcClient = {
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
export type OidcClientCreateWithLogo = OidcClientCreate & {
logo: File | null;
logo: File | null | undefined;
};
export type AuthorizeResponse = {

View File

@@ -24,7 +24,7 @@
let authorizationRequired = false;
export let data: PageData;
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
async function authorize() {
isLoading = true;
@@ -55,7 +55,14 @@
isLoading = true;
try {
await oidService
.authorizeNewClient(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
.authorizeNewClient(
client!.id,
scope,
callbackURL,
nonce,
codeChallenge,
codeChallengeMethod
)
.then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL);
});
@@ -68,7 +75,11 @@
function onSuccess(code: string, callbackURL: string) {
success = true;
setTimeout(() => {
window.location.href = `${callbackURL}?code=${code}&state=${state}`;
const redirectURL = new URL(callbackURL);
redirectURL.searchParams.append('code', code);
redirectURL.searchParams.append('state', state);
window.location.href = redirectURL.toString();
}, 1000);
}
</script>

View File

@@ -1,19 +1,21 @@
<script>
<script lang="ts">
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
import { toast } from 'svelte-sonner';
import { fade } from 'svelte/transition';
import LoginLogoErrorIndicator from './components/login-logo-error-indicator.svelte';
const webauthnService = new WebAuthnService();
let isLoading = $state(false);
let error: string | undefined = $state(undefined);
async function authenticate() {
error = undefined;
isLoading = true;
try {
const loginOptions = await webauthnService.getLoginOptions();
@@ -23,7 +25,7 @@
userStore.setUser(user);
goto('/settings');
} catch (e) {
toast.error(getWebauthnErrorMessage(e));
error = getWebauthnErrorMessage(e);
}
isLoading = false;
}
@@ -35,15 +37,21 @@
<SignInWrapper>
<div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
<LoginLogoErrorIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$appConfigStore.appName}
</h1>
<p class="text-muted-foreground mt-2">
Authenticate yourself with your passkey to access the admin panel
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Authenticate</Button>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. Please try to sign in again.
</p>
{:else}
<p class="text-muted-foreground mt-2" in:fade>
Authenticate yourself with your passkey to access the admin panel.
</p>
{/if}
<Button class="mt-10" {isLoading} on:click={authenticate}
>{error ? 'Try again' : 'Authenticate'}</Button
>
</SignInWrapper>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import Logo from '$lib/components/logo.svelte';
import CrossAnimated from '$lib/icons/cross-animated.svelte';
import { fade } from 'svelte/transition';
const {
error
}: {
error: boolean;
} = $props();
</script>
<div
class="rounded-2xl p-3 transition-[background-color] duration-300
{error ? 'bg-red-200' : 'bg-muted'}"
>
{#if error}
<div class="flex h-10 w-10 items-center justify-center">
<CrossAnimated class="h-5 w-5" />
</div>
{:else}
<div in:fade={{ duration: 300 }}>
<Logo class="h-10 w-10" />
</div>
{/if}
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import UserService from '$lib/services/user-service';
@@ -8,6 +9,7 @@
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
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';
@@ -52,6 +54,16 @@
<title>Account Settings</title>
</svelte:head>
{#if passkeys.length == 0}
<Alert.Root variant="warning">
<LucideAlertTriangle class="size-4" />
<Alert.Title>Passkey missing</Alert.Title>
<Alert.Description
>Please add a passkey to prevent losing access to your account.</Alert.Description
>
</Alert.Root>
{/if}
{#if $appConfigStore.allowOwnAccountEdit}
<Card.Root>
<Card.Header>
@@ -77,7 +89,7 @@
</Card.Header>
{#if passkeys.length != 0}
<Card.Content>
<PasskeyList {passkeys} />
<PasskeyList bind:passkeys />
</Card.Content>
{/if}
</Card.Root>

View File

@@ -16,9 +16,16 @@
let isLoading = $state(false);
const formSchema = z.object({
firstName: z.string().min(2).max(50),
lastName: z.string().min(2).max(50),
username: z.string().min(2).max(50),
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
username: z
.string()
.min(2)
.max(30)
.regex(
/^[a-z0-9_@.-]+$/,
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols"
),
email: z.string().email(),
isAdmin: z.boolean()
});

View File

@@ -9,15 +9,10 @@
import { toast } from 'svelte-sonner';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
let { passkeys: initialsPasskeys }: { passkeys: Passkey[] } = $props();
let passkeys = $state<Passkey[]>(initialsPasskeys);
let { passkeys = $bindable() }: { passkeys: Passkey[] } = $props();
const webauthnService = new WebauthnService();
$effect(() => {
passkeys = initialsPasskeys;
});
let passkeyToRename: Passkey | null = $state(null);
async function deletePasskey(passkey: Passkey) {

View File

@@ -33,7 +33,10 @@
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
let success = true;
const dataPromise = oidcService.updateClient(client.id, updatedClient);
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
const imagePromise =
updatedClient.logo !== undefined
? oidcService.updateClientLogo(client, updatedClient.logo)
: Promise.resolve();
client.isPublic = updatedClient.isPublic;

View File

@@ -22,7 +22,7 @@
} = $props();
let isLoading = $state(false);
let logo = $state<File | null>(null);
let logo = $state<File | null | undefined>();
let logoDataURL: string | null = $state(
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
);
@@ -108,7 +108,7 @@
onchange={onLogoChange}
>
<Button variant="secondary">
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
{logoDataURL ? 'Change Logo' : 'Upload Logo'}
</Button>
</FileInput>
{#if logoDataURL}

View File

@@ -25,8 +25,8 @@
};
const formSchema = z.object({
firstName: z.string().min(2).max(30),
lastName: z.string().min(2).max(30),
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
username: z
.string()
.min(2)

View File

@@ -55,3 +55,8 @@ export const userGroups = {
name: 'human_resources'
}
};
export const oneTimeAccessTokens = [
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
{ token: 'YCGDtftvsvYWiXd0', expired: true }
];

View File

@@ -0,0 +1,21 @@
import test, { expect } from '@playwright/test';
import { oneTimeAccessTokens } from './data';
// Disable authentication for these tests
test.use({ storageState: { cookies: [], origins: [] } });
test('Sign in with one time access token', async ({ page }) => {
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
await page.goto(`/login/${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}`);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('status')).toHaveText('Token is invalid or expired');
});

View File

@@ -1,5 +1,6 @@
# Default database path
DB_PATH="./backend/data/pocket-id.db"
DB_PROVIDER="${DB_PROVIDER:=sqlite}"
USER_IDENTIFIER="$1"
# Parse command-line arguments for the -d flag (database path)
while getopts ":d:" opt; do
@@ -19,12 +20,12 @@ shift $((OPTIND - 1))
# Ensure username or email is provided as a parameter
if [ -z "$1" ]; then
echo "Usage: $0 [-d <database_path>] <username or email>"
echo " -d Specify the database path (optional, defaults to ./backend/data/pocket-id.db)"
if [ "$DB_PROVIDER" == "sqlite" ]; then
echo "-d <database_path> (optional): Path to the SQLite database file. Default: $DB_PATH"
fi
exit 1
fi
USER_IDENTIFIER="$1"
# Check and try to install the required commands
check_and_install() {
local cmd=$1
@@ -41,8 +42,12 @@ check_and_install() {
fi
}
check_and_install sqlite3 sqlite
check_and_install uuidgen uuidgen
if [ "$DB_PROVIDER" == "postgres" ]; then
check_and_install psql postgresql-client
elif [ "$DB_PROVIDER" == "sqlite" ]; then
check_and_install sqlite3 sqlite
fi
# Generate a 16-character alphanumeric secret token
SECRET_TOKEN=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)
@@ -51,21 +56,47 @@ SECRET_TOKEN=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)
CREATED_AT=$(date +%s)
EXPIRES_AT=$((CREATED_AT + 3600))
# Retrieve user_id from the users table based on username or email
USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';")
# Retrieve user_id based on username or email and insert token
if [ "$DB_PROVIDER" == "postgres" ]; then
if [ -z "$POSTGRES_CONNECTION_STRING" ]; then
echo "Error: POSTGRES_CONNECTION_STRING must be set when using PostgreSQL."
exit 1
fi
# Check if user exists
if [ -z "$USER_ID" ]; then
echo "User not found for username/email: $USER_IDENTIFIER"
exit 1
fi
# Retrieve user_id
USER_ID=$(psql "$POSTGRES_CONNECTION_STRING" -Atc "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';")
# Insert the one-time token into the one_time_access_tokens table
sqlite3 "$DB_PATH" <<EOF
if [ -z "$USER_ID" ]; then
echo "User not found for username/email: $USER_IDENTIFIER"
exit 1
fi
# Insert the one-time token
psql "$POSTGRES_CONNECTION_STRING" <<EOF
INSERT INTO one_time_access_tokens (id, created_at, token, expires_at, user_id)
VALUES ('$(uuidgen)', to_timestamp('$CREATED_AT'), '$SECRET_TOKEN', to_timestamp('$EXPIRES_AT'), '$USER_ID');
EOF
elif [ "$DB_PROVIDER" == "sqlite" ]; then
# Retrieve user_id
USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';")
if [ -z "$USER_ID" ]; then
echo "User not found for username/email: $USER_IDENTIFIER"
exit 1
fi
# Insert the one-time token
sqlite3 "$DB_PATH" <<EOF
INSERT INTO one_time_access_tokens (id, created_at, token, expires_at, user_id)
VALUES ('$(uuidgen)', '$CREATED_AT', '$SECRET_TOKEN', '$EXPIRES_AT', '$USER_ID');
EOF
else
echo "Error: Invalid DB_PROVIDER. Must be 'postgres' or 'sqlite'."
exit 1
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"
@@ -73,3 +104,4 @@ else
echo "Error creating access token."
exit 1
fi
echo "================================================="