mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-29 02:36:35 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee885fbff5 | ||
|
|
333a1a18d5 | ||
|
|
1ff20caa3c | ||
|
|
f6f2736bba | ||
|
|
993330d932 | ||
|
|
204313aacf | ||
|
|
0729ce9e1a | ||
|
|
2d0bd8dcbf | ||
|
|
ff75322e7d | ||
|
|
daced661c4 | ||
|
|
0716c38fb8 | ||
|
|
789d9394a5 | ||
|
|
aeda512cb7 | ||
|
|
5480ab0f18 | ||
|
|
bad901ea2b | ||
|
|
34e35193f9 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -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)
|
## [](https://github.com/stonith404/pocket-id/compare/v0.19.0...v) (2024-12-12)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -17,7 +17,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
|
|||||||
|
|
||||||
### Before you start
|
### 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)
|
### Installation with Docker (recommended)
|
||||||
|
|
||||||
@@ -78,14 +78,14 @@ Required tools:
|
|||||||
|
|
||||||
# Optional: Start Caddy (You can use any other reverse proxy)
|
# Optional: Start Caddy (You can use any other reverse proxy)
|
||||||
cd ..
|
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`.
|
You can now sign in with the admin account on `http://localhost/login/setup`.
|
||||||
|
|
||||||
### Nginx Reverse Proxy
|
### 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
|
```nginx
|
||||||
proxy_busy_buffers_size 512k;
|
proxy_busy_buffers_size 512k;
|
||||||
@@ -95,7 +95,7 @@ proxy_buffer_size 256k;
|
|||||||
|
|
||||||
## Proxy Services with Pocket ID
|
## 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.
|
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)
|
# Optional: Start Caddy (You can use any other reverse proxy)
|
||||||
cd ..
|
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
|
## Environment variables
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ type UserDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto 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"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
FirstName string `json:"firstName" binding:"required,min=3,max=30"`
|
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
||||||
LastName string `json:"lastName" binding:"required,min=3,max=30"`
|
LastName string `json:"lastName" binding:"required,min=1,max=50"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"github.com/go-co-op/gocron/v2"
|
"github.com/go-co-op/gocron/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,22 +30,22 @@ type Jobs struct {
|
|||||||
|
|
||||||
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
||||||
func (j *Jobs) clearWebauthnSessions() error {
|
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
|
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
||||||
func (j *Jobs) clearOneTimeAccessTokens() error {
|
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
|
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||||
func (j *Jobs) clearOidcAuthorizationCodes() error {
|
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
|
// ClearAuditLogs deletes audit logs older than 90 days
|
||||||
func (j *Jobs) clearAuditLogs() error {
|
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) {
|
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"time"
|
"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
|
type DateTime time.Time
|
||||||
|
|
||||||
func (date *DateTime) Scan(value interface{}) (err error) {
|
func (date *DateTime) Scan(value interface{}) (err error) {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
|
||||||
"log"
|
"log"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
@@ -96,7 +95,7 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
|||||||
Subject: user.ID,
|
Subject: user.ID,
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
Audience: jwt.ClaimStrings{utils.GetHostFromURL(common.EnvConfig.AppURL)},
|
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL},
|
||||||
},
|
},
|
||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
}
|
}
|
||||||
@@ -125,7 +124,7 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaim
|
|||||||
return nil, errors.New("can't parse claims")
|
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 nil, errors.New("audience doesn't match")
|
||||||
}
|
}
|
||||||
return claims, nil
|
return claims, nil
|
||||||
|
|||||||
@@ -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{
|
userGroups := []model.UserGroup{
|
||||||
{
|
{
|
||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
|
|||||||
|
|
||||||
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
|
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
|
||||||
var oneTimeAccessToken model.OneTimeAccessToken
|
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) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type WebAuthnService struct {
|
|||||||
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
|
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
|
||||||
webauthnConfig := &webauthn.Config{
|
webauthnConfig := &webauthn.Config{
|
||||||
RPDisplayName: appConfigService.DbConfig.AppName.Value,
|
RPDisplayName: appConfigService.DbConfig.AppName.Value,
|
||||||
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
|
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
|
||||||
RPOrigins: []string{common.EnvConfig.AppURL},
|
RPOrigins: []string{common.EnvConfig.AppURL},
|
||||||
Timeouts: webauthn.TimeoutsConfig{
|
Timeouts: webauthn.TimeoutsConfig{
|
||||||
Login: webauthn.TimeoutConfig{
|
Login: webauthn.TimeoutConfig{
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ func GenerateRandomAlphanumericString(length int) (string, error) {
|
|||||||
return string(result), nil
|
return string(result), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHostFromURL(rawURL string) string {
|
func GetHostnameFromURL(rawURL string) string {
|
||||||
parsedURL, err := url.Parse(rawURL)
|
parsedURL, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return parsedURL.Host
|
return parsedURL.Hostname()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StringPointer creates a string pointer from a string value
|
// StringPointer creates a string pointer from a string value
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ upstreams="http://<service-to-be-proxied>:<port>"
|
|||||||
|
|
||||||
# Additional Configuration
|
# Additional Configuration
|
||||||
provider="oidc"
|
provider="oidc"
|
||||||
scope = "openid email profile"
|
scope = "openid email profile groups"
|
||||||
|
|
||||||
# If you are using a reverse proxy in front of OAuth2 Proxy
|
# If you are using a reverse proxy in front of OAuth2 Proxy
|
||||||
reverse_proxy = true
|
reverse_proxy = true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "0.20.0",
|
"version": "0.22.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --port 3000",
|
"dev": "vite dev --port 3000",
|
||||||
|
|||||||
@@ -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>
|
||||||
21
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
21
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal 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>
|
||||||
17
frontend/src/lib/components/ui/alert/alert.svelte
Normal file
17
frontend/src/lib/components/ui/alert/alert.svelte
Normal 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>
|
||||||
35
frontend/src/lib/components/ui/alert/index.ts
Normal file
35
frontend/src/lib/components/ui/alert/index.ts
Normal 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
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ export type OidcClient = {
|
|||||||
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
||||||
|
|
||||||
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
||||||
logo: File | null;
|
logo: File | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthorizeResponse = {
|
export type AuthorizeResponse = {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
let authorizationRequired = false;
|
let authorizationRequired = false;
|
||||||
|
|
||||||
export let data: PageData;
|
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() {
|
async function authorize() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -55,7 +55,14 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
await oidService
|
await oidService
|
||||||
.authorizeNewClient(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
.authorizeNewClient(
|
||||||
|
client!.id,
|
||||||
|
scope,
|
||||||
|
callbackURL,
|
||||||
|
nonce,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod
|
||||||
|
)
|
||||||
.then(async ({ code, callbackURL }) => {
|
.then(async ({ code, callbackURL }) => {
|
||||||
onSuccess(code, callbackURL);
|
onSuccess(code, callbackURL);
|
||||||
});
|
});
|
||||||
@@ -68,7 +75,11 @@
|
|||||||
function onSuccess(code: string, callbackURL: string) {
|
function onSuccess(code: string, callbackURL: string) {
|
||||||
success = true;
|
success = true;
|
||||||
setTimeout(() => {
|
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);
|
}, 1000);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||||
import Logo from '$lib/components/logo.svelte';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import WebAuthnService from '$lib/services/webauthn-service';
|
import WebAuthnService from '$lib/services/webauthn-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
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();
|
const webauthnService = new WebAuthnService();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
let error: string | undefined = $state(undefined);
|
||||||
|
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
|
error = undefined;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const loginOptions = await webauthnService.getLoginOptions();
|
const loginOptions = await webauthnService.getLoginOptions();
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
userStore.setUser(user);
|
userStore.setUser(user);
|
||||||
goto('/settings');
|
goto('/settings');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(getWebauthnErrorMessage(e));
|
error = getWebauthnErrorMessage(e);
|
||||||
}
|
}
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
@@ -35,15 +37,21 @@
|
|||||||
|
|
||||||
<SignInWrapper>
|
<SignInWrapper>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<div class="bg-muted rounded-2xl p-3">
|
<LoginLogoErrorIndicator error={!!error} />
|
||||||
<Logo class="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="font-playfair mt-5 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>
|
||||||
<p class="text-muted-foreground mt-2">
|
{#if error}
|
||||||
Authenticate yourself with your passkey to access the admin panel
|
<p class="text-muted-foreground mt-2" in:fade>
|
||||||
</p>
|
{error}. Please try to sign in again.
|
||||||
<Button class="mt-5" {isLoading} on:click={authenticate}>Authenticate</Button>
|
</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>
|
</SignInWrapper>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
import type { UserCreate } from '$lib/types/user.type';
|
import type { UserCreate } from '$lib/types/user.type';
|
||||||
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
|
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
|
import { LucideAlertTriangle } from 'lucide-svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import AccountForm from './account-form.svelte';
|
import AccountForm from './account-form.svelte';
|
||||||
import PasskeyList from './passkey-list.svelte';
|
import PasskeyList from './passkey-list.svelte';
|
||||||
@@ -52,6 +54,16 @@
|
|||||||
<title>Account Settings</title>
|
<title>Account Settings</title>
|
||||||
</svelte:head>
|
</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}
|
{#if $appConfigStore.allowOwnAccountEdit}
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
@@ -77,7 +89,7 @@
|
|||||||
</Card.Header>
|
</Card.Header>
|
||||||
{#if passkeys.length != 0}
|
{#if passkeys.length != 0}
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<PasskeyList {passkeys} />
|
<PasskeyList bind:passkeys />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -16,9 +16,16 @@
|
|||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
firstName: z.string().min(2).max(50),
|
firstName: z.string().min(1).max(50),
|
||||||
lastName: z.string().min(2).max(50),
|
lastName: z.string().min(1).max(50),
|
||||||
username: z.string().min(2).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(),
|
email: z.string().email(),
|
||||||
isAdmin: z.boolean()
|
isAdmin: z.boolean()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,15 +9,10 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import RenamePasskeyModal from './rename-passkey-modal.svelte';
|
import RenamePasskeyModal from './rename-passkey-modal.svelte';
|
||||||
|
|
||||||
let { passkeys: initialsPasskeys }: { passkeys: Passkey[] } = $props();
|
let { passkeys = $bindable() }: { passkeys: Passkey[] } = $props();
|
||||||
let passkeys = $state<Passkey[]>(initialsPasskeys);
|
|
||||||
|
|
||||||
const webauthnService = new WebauthnService();
|
const webauthnService = new WebauthnService();
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
passkeys = initialsPasskeys;
|
|
||||||
});
|
|
||||||
|
|
||||||
let passkeyToRename: Passkey | null = $state(null);
|
let passkeyToRename: Passkey | null = $state(null);
|
||||||
|
|
||||||
async function deletePasskey(passkey: Passkey) {
|
async function deletePasskey(passkey: Passkey) {
|
||||||
|
|||||||
@@ -33,7 +33,10 @@
|
|||||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||||
let success = true;
|
let success = true;
|
||||||
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
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;
|
client.isPublic = updatedClient.isPublic;
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let logo = $state<File | null>(null);
|
let logo = $state<File | null | undefined>();
|
||||||
let logoDataURL: string | null = $state(
|
let logoDataURL: string | null = $state(
|
||||||
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
|
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
|
||||||
);
|
);
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
onchange={onLogoChange}
|
onchange={onLogoChange}
|
||||||
>
|
>
|
||||||
<Button variant="secondary">
|
<Button variant="secondary">
|
||||||
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
|
{logoDataURL ? 'Change Logo' : 'Upload Logo'}
|
||||||
</Button>
|
</Button>
|
||||||
</FileInput>
|
</FileInput>
|
||||||
{#if logoDataURL}
|
{#if logoDataURL}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
firstName: z.string().min(2).max(30),
|
firstName: z.string().min(1).max(50),
|
||||||
lastName: z.string().min(2).max(30),
|
lastName: z.string().min(1).max(50),
|
||||||
username: z
|
username: z
|
||||||
.string()
|
.string()
|
||||||
.min(2)
|
.min(2)
|
||||||
|
|||||||
@@ -55,3 +55,8 @@ export const userGroups = {
|
|||||||
name: 'human_resources'
|
name: 'human_resources'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const oneTimeAccessTokens = [
|
||||||
|
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
|
||||||
|
{ token: 'YCGDtftvsvYWiXd0', expired: true }
|
||||||
|
];
|
||||||
|
|||||||
21
frontend/tests/one-time-access-token.spec.ts
Normal file
21
frontend/tests/one-time-access-token.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# Default database path
|
|
||||||
DB_PATH="./backend/data/pocket-id.db"
|
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)
|
# Parse command-line arguments for the -d flag (database path)
|
||||||
while getopts ":d:" opt; do
|
while getopts ":d:" opt; do
|
||||||
@@ -19,12 +20,12 @@ shift $((OPTIND - 1))
|
|||||||
# Ensure username or email is provided as a parameter
|
# Ensure username or email is provided as a parameter
|
||||||
if [ -z "$1" ]; then
|
if [ -z "$1" ]; then
|
||||||
echo "Usage: $0 [-d <database_path>] <username or email>"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
USER_IDENTIFIER="$1"
|
|
||||||
|
|
||||||
# Check and try to install the required commands
|
# Check and try to install the required commands
|
||||||
check_and_install() {
|
check_and_install() {
|
||||||
local cmd=$1
|
local cmd=$1
|
||||||
@@ -41,8 +42,12 @@ check_and_install() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
check_and_install sqlite3 sqlite
|
|
||||||
check_and_install uuidgen uuidgen
|
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
|
# Generate a 16-character alphanumeric secret token
|
||||||
SECRET_TOKEN=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)
|
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)
|
CREATED_AT=$(date +%s)
|
||||||
EXPIRES_AT=$((CREATED_AT + 3600))
|
EXPIRES_AT=$((CREATED_AT + 3600))
|
||||||
|
|
||||||
# Retrieve user_id from the users table based on username or email
|
# Retrieve user_id based on username or email and insert token
|
||||||
USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';")
|
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
|
# Retrieve user_id
|
||||||
if [ -z "$USER_ID" ]; then
|
USER_ID=$(psql "$POSTGRES_CONNECTION_STRING" -Atc "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';")
|
||||||
echo "User not found for username/email: $USER_IDENTIFIER"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Insert the one-time token into the one_time_access_tokens table
|
if [ -z "$USER_ID" ]; then
|
||||||
sqlite3 "$DB_PATH" <<EOF
|
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)
|
INSERT INTO one_time_access_tokens (id, created_at, token, expires_at, user_id)
|
||||||
VALUES ('$(uuidgen)', '$CREATED_AT', '$SECRET_TOKEN', '$EXPIRES_AT', '$USER_ID');
|
VALUES ('$(uuidgen)', '$CREATED_AT', '$SECRET_TOKEN', '$EXPIRES_AT', '$USER_ID');
|
||||||
EOF
|
EOF
|
||||||
|
else
|
||||||
|
echo "Error: Invalid DB_PROVIDER. Must be 'postgres' or 'sqlite'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
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>}/login/$SECRET_TOKEN"
|
||||||
@@ -73,3 +104,4 @@ else
|
|||||||
echo "Error creating access token."
|
echo "Error creating access token."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo "================================================="
|
||||||
|
|||||||
Reference in New Issue
Block a user