Compare commits

...

17 Commits

Author SHA1 Message Date
Elias Schneider
3f29325f45 release: 0.14.0 2024-11-11 18:26:15 +01:00
Elias Schneider
aca2240a50 feat: add audit log event for one time access token sign in 2024-11-11 18:25:57 +01:00
Elias Schneider
de45398903 fix: overflow of pagination control on mobile 2024-11-11 18:09:17 +01:00
Elias Schneider
3d3fb4d855 fix: time displayed incorrectly in audit log 2024-11-11 18:02:19 +01:00
Elias Schneider
725388fcc7 chore: fix build warnings 2024-11-02 00:04:27 +01:00
Elias Schneider
ad1d3560f9 release: 0.13.1 2024-11-01 23:52:30 +01:00
Elias Schneider
becfc0004a feat: add list empty indicator 2024-11-01 23:52:01 +01:00
Elias Schneider
376d747616 fix: errors in middleware do not abort the request 2024-11-01 23:41:57 +01:00
Elias Schneider
5b9f4d7326 fix: typo in Self-Account Editing description 2024-11-01 23:33:50 +01:00
Elias Schneider
0de4b55dc4 release: 0.13.0 2024-10-31 18:13:54 +01:00
Elias Schneider
78c88f5339 docs: add nginx configuration to README 2024-10-31 18:13:18 +01:00
Elias Schneider
60e7dafa01 Revert "fix: bad gateway error if nginx reverse proxy is in front"
This reverts commit 590cb02f6c.
2024-10-31 17:50:52 +01:00
Elias Schneider
2ccabf835c feat: add ability to define expiration of one time link 2024-10-31 17:22:58 +01:00
Elias Schneider
590cb02f6c fix: bad gateway error if nginx reverse proxy is in front 2024-10-31 14:15:57 +01:00
Elias Schneider
8c96ab9574 Merge branch 'main' of https://github.com/stonith404/pocket-id 2024-10-30 11:53:44 +01:00
Elias Schneider
3484daf870 chore: change default port in dockerfile 2024-10-30 11:53:36 +01:00
Kevin Cayouette
cfbc0d6d35 docs: add Jellyfin Integration Guide (#51) 2024-10-28 18:55:16 +01:00
31 changed files with 349 additions and 170 deletions

View File

@@ -1 +1 @@
0.12.0
0.14.0

View File

@@ -1,3 +1,36 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11)
### Features
* add audit log event for one time access token sign in ([aca2240](https://github.com/stonith404/pocket-id/commit/aca2240a50a12e849cfb6e1aa56390b000aebae0))
### Bug Fixes
* overflow of pagination control on mobile ([de45398](https://github.com/stonith404/pocket-id/commit/de4539890349153c467013c24c4d6b30feb8fed8))
* time displayed incorrectly in audit log ([3d3fb4d](https://github.com/stonith404/pocket-id/commit/3d3fb4d855ef510f2292e98fcaaaf83debb5d3e0))
## [](https://github.com/stonith404/pocket-id/compare/v0.13.0...v) (2024-11-01)
### Features
* add list empty indicator ([becfc00](https://github.com/stonith404/pocket-id/commit/becfc0004a87c01e18eb92ac85bf4e33f105b6a3))
### Bug Fixes
* errors in middleware do not abort the request ([376d747](https://github.com/stonith404/pocket-id/commit/376d747616b1e835f252d20832c5ae42b8b0b737))
* typo in Self-Account Editing description ([5b9f4d7](https://github.com/stonith404/pocket-id/commit/5b9f4d732615f428c13d3317da96a86c5daebd89))
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)
### Features
* add ability to define expiration of one time link ([2ccabf8](https://github.com/stonith404/pocket-id/commit/2ccabf835c2c923d6986d9cafb4e878f5110b91a))
## [](https://github.com/stonith404/pocket-id/compare/v0.11.0...v) (2024-10-28)

View File

@@ -38,7 +38,7 @@ COPY --from=backend-builder /app/backend/images ./backend/images
COPY ./scripts ./scripts
RUN chmod +x ./scripts/*.sh
EXPOSE 3000
EXPOSE 80
ENV APP_ENV=production
# Use a shell form to run both the frontend and backend

View File

@@ -85,28 +85,23 @@ Required tools:
You can now sign in with the admin account on `http://localhost/login/setup`.
### Add Pocket ID as an OIDC provider
### Nginx Reverse Proxy
You can add a new OIDC client on `https://<your-domain>/settings/admin/oidc-clients`
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
After you have added the client, you can obtain the client ID and client secret.
```nginx
proxy_busy_buffers_size 512k;
proxy_buffers 4 512k;
proxy_buffer_size 256k;
```
You may need the following information:
- **Authorization URL**: `https://<your-domain>/authorize`
- **Token URL**: `https://<your-domain>/api/oidc/token`
- **Userinfo URL**: `https://<your-domain>/api/oidc/userinfo`
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
- **Scopes**: At least `openid email`. Optionally you can add `profile` and `groups`.
### 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/) to add authentication to your services that don't support OIDC.
See the [guide](docs/proxy-services.md) for more information.
### Update
## Update
#### Docker
@@ -149,7 +144,7 @@ docker compose up -d
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
```
### Environment variables
## Environment variables
| Variable | Default Value | Recommended to change | Description |
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -38,7 +38,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService)
userService := service.NewUserService(db, jwtService, auditLogService)
customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService)

View File

@@ -141,7 +141,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
return
}
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt, c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
return

View File

@@ -2,12 +2,12 @@ package dto
import (
"github.com/stonith404/pocket-id/backend/internal/model"
"time"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
)
type AuditLogDto struct {
ID string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
CreatedAt datatype.DateTime `json:"createdAt"`
Event model.AuditLogEvent `json:"event"`
IpAddress string `json:"ipAddress"`

View File

@@ -1,23 +1,25 @@
package dto
import "time"
import (
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
)
type UserGroupDtoWithUsers struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
Users []UserDto `json:"users"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
Users []UserDto `json:"users"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupDtoWithUserCount struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserCount int64 `json:"userCount"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserCount int64 `json:"userCount"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupCreateDto struct {

View File

@@ -2,7 +2,7 @@ package dto
import (
"github.com/go-webauthn/webauthn/protocol"
"time"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
)
type WebauthnCredentialDto struct {
@@ -15,7 +15,7 @@ type WebauthnCredentialDto struct {
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`
CreatedAt time.Time `json:"createdAt"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type WebauthnCredentialUpdateDto struct {

View File

@@ -19,6 +19,7 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
c.Error(err)
c.Abort()
return
}
c.Next()

View File

@@ -29,6 +29,7 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
return
} else {
c.Error(&common.NotSignedInError{})
c.Abort()
return
}
}

View File

@@ -32,6 +32,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
limiter := getLimiter(ip, limit, burst)
if !limiter.Allow() {
c.Error(&common.TooManyRequestsError{})
c.Abort()
return
}

View File

@@ -23,9 +23,10 @@ type AuditLogData map[string]string
type AuditLogEvent string
const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
)
// Scan and Value methods for GORM to handle the custom type

View File

@@ -48,8 +48,8 @@ func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent
}
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, data)
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string) model.AuditLog {
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
// Count the number of times the user has logged in from the same device
var count int64

View File

@@ -12,12 +12,13 @@ import (
)
type UserService struct {
db *gorm.DB
jwtService *JwtService
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
}
func NewUserService(db *gorm.DB, jwtService *JwtService) *UserService {
return &UserService{db: db, jwtService: jwtService}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService) *UserService {
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService}
}
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
@@ -88,7 +89,7 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
return user, nil
}
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time, ipAddress, userAgent string) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return "", err
@@ -104,6 +105,8 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
return "", err
}
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
return oneTimeAccessToken.Token, nil
}

View File

@@ -165,7 +165,7 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
return model.User{}, "", err
}
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID, model.AuditLogData{})
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID)
return *user, token, nil
}

BIN
docs/imgs/jelly_fin_img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

55
docs/jellyfin.md Normal file
View File

@@ -0,0 +1,55 @@
# Jellyfin SSO Integration Guide
> Due to the current limitations of the Jellyfin SSO plugin, this integration will only work in a browser. When tested, the Jellyfin app did not work and displayed an error, even when custom menu buttons were created.
> To view the original references and a full list of capabilities, please visit the [Jellyfin SSO OpenID Section](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#openid).
### Requirements
- [Jellyfin Server](https://jellyfin.org/downloads/server)
- [Jellyfin SSO Plugin](https://github.com/9p4/jellyfin-plugin-sso)
- HTTPS connection to your Jellyfin server
### OIDC - Pocket ID Setup
To start, we need to create a new SSO resource in our Jellyfin application.
> Replace the `JELLYFINDOMAIN` and `PROVIDER` elements in the URL.
1. Log into the admin panel, and go to OIDC Clients -> Add OIDC Client.
2. **Name**: Jellyfin (or any name you prefer)
3. **Callback URL**: `https://JELLYFINDOMAIN.com/sso/OID/redirect/PROVIDER`
4. For this example, well be using the provider named "test_resource."
5. Click **Save**. Keep the page open, as we will need the OID client ID and OID secret.
### OIDC Client - Jellyfin SSO Resource
1. Visit the plugin page (<i>Administration Dashboard -> My Plugins -> SSO-Auth</i>).
2. Enter the <i>OID Provider Name (we used "test_resource" as our name in the callback URL), Open ID, OID Secret, and mark it as enabled.</i>
3. The following steps are optional based on your needs. In this guide, well be managing only regular users, not admins.
![img.png](imgs/jelly_fin_img.png)
> To manage user access through groups, follow steps **4, 5, and 6**. Otherwise, leave it blank and skip to step 7.
![img2.png](imgs/jelly_fin_img2.png)
4. Under <i>Roles</i>, type the name of the group you want to use. **Note:** This must be the group name, not the label. Double-check in Pocket ID, as an incorrect name will lock users out.
5. Skip every field until you reach the **Role Claim** field, and type `groups`.
> This step is crucial if you want to manage users through groups.
6. Repeat the above step under **Request Additional Scopes**. This will pull the group scope during the sign-in process; otherwise, the previous steps wont work.
![img3.png](imgs/jelly_fin_img3.png)
7. Skip the remaining fields until you reach **Scheme Override**. Enter `https` here. If omitted, it will attempt to use HTTP first, which will break as WebAuthn requires an HTTPS connection.
8. Click **Save** and restart Jellyfin.
### Optional Step - Custom Home Button
Follow the [guide to create a login button on the login page](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#creating-a-login-button-on-the-main-page) to add a custom button on your sign-in page. This step is optional, as you could also provide the sign-in URL via a bookmark or other means.
### Signing into Your Jellyfin Instance
Done! You have successfully set up SSO for your Jellyfin instance using Pocket ID.
> **Note:** Sometimes there may be a brief delay when using the custom menu option. This is related to the Jellyfin plugin and not Pocket ID.
If your users already have accounts, as long as their Pocket ID username matches their Jellyfin ID, they will be logged in automatically. Otherwise, a new user will be created with access to all of your folders. Of course, you can modify this in your configuration as desired.
This setup will only work if sign-in is performed using the `https://jellyfin.example.com/sso/OID/start/PROVIDER` URL. This URL initiates the SSO plugin and applies all the configurations we completed above.

View File

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

View File

@@ -4,6 +4,7 @@
import * as Pagination from '$lib/components/ui/pagination';
import * as Select from '$lib/components/ui/select';
import * as Table from '$lib/components/ui/table/index.js';
import Empty from '$lib/icons/empty.svelte';
import type { Paginated } from '$lib/types/pagination.type';
import { debounced } from '$lib/utils/debounce-util';
import type { Snippet } from 'svelte';
@@ -66,93 +67,104 @@
}
</script>
<div class="w-full">
{#if !withoutSearch}
<Input
class="mb-4 max-w-sm"
placeholder={'Search...'}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
{/if}
<Table.Root>
<Table.Header>
<Table.Row>
{#if selectedIds}
<Table.Head>
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
</Table.Head>
{/if}
{#each columns as column}
{#if typeof column === 'string'}
<Table.Head>{column}</Table.Head>
{:else}
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head>
{/if}
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each items.data as item}
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
{#if selectedIds}
<Table.Cell>
<Checkbox
checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
/>
</Table.Cell>
{/if}
{@render rows({ item })}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div class="mt-5 flex items-center justify-between space-x-2">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),
value: items.pagination.itemsPerPage
}}
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
>
<Select.Trigger class="h-9 w-[80px]">
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items.pagination.totalItems}
perPage={items.pagination.itemsPerPage}
{onPageChange}
page={items.pagination.currentPage}
let:pages
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis'}
<Pagination.Item>
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
{#if items.data.length === 0}
<div class="my-5 flex flex-col items-center">
<Empty class="text-muted-foreground h-20" />
<p class="text-muted-foreground mt-3 text-sm">No items found</p>
</div>
</div>
{:else}
<div class="w-full">
{#if !withoutSearch}
<Input
class="mb-4 max-w-sm"
placeholder={'Search...'}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
{/if}
<Table.Root>
<Table.Header>
<Table.Row>
{#if selectedIds}
<Table.Head>
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
</Table.Head>
{/if}
{#each columns as column}
{#if typeof column === 'string'}
<Table.Head>{column}</Table.Head>
{:else}
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head>
{/if}
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each items.data as item}
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
{#if selectedIds}
<Table.Cell>
<Checkbox
checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
/>
</Table.Cell>
{/if}
{@render rows({ item })}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div
class="mt-5 flex flex-col-reverse items-center justify-between gap-3 space-x-2 sm:flex-row"
>
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),
value: items.pagination.itemsPerPage
}}
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
>
<Select.Trigger class="h-9 w-[80px]">
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items.pagination.totalItems}
perPage={items.pagination.itemsPerPage}
{onPageChange}
page={items.pagination.currentPage}
let:pages
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis'}
<Pagination.Item>
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
</div>
</div>
{/if}

View File

@@ -2,7 +2,7 @@ import { type VariantProps, tv } from "tailwind-variants";
export { default as Badge } from "./badge.svelte";
export const badgeVariants = tv({
base: "inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
base: "inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 break-keep whitespace-nowrap",
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",

View File

@@ -10,7 +10,7 @@
</script>
<span
aria-hidden
aria-hidden="true"
class={cn("flex h-9 w-9 items-center justify-center", className)}
{...$$restProps}
>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
let {
class: className
}: {
class?: string;
} = $props();
</script>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 336.19673868301203 129.38671875"
class={className}
>
<g stroke-linecap="round" transform="translate(10 10) rotate(0 158.09836934150601 54.693359375)">
<path
d="M27.35 0 C121.36 -0.62, 208.79 0.52, 288.85 0 M288.85 0 C305.5 3.32, 316.8 6.14, 316.2 27.35 M316.2 27.35 C315.58 42.15, 314.92 54.54, 316.2 82.04 M316.2 82.04 C313.79 100.68, 304.9 110.1, 288.85 109.39 M288.85 109.39 C192.86 108.68, 93.17 110.07, 27.35 109.39 M27.35 109.39 C13.09 109.46, -1.61 102.22, 0 82.04 M0 82.04 C-0.35 60.8, -1.11 41.01, 0 27.35 M0 27.35 C1.94 9.62, 8.6 1.41, 27.35 0"
stroke="#A1A1AA"
stroke-width="4.5"
fill="none"
stroke-dasharray="8 12"
></path>
</g>
</svg>

View File

@@ -42,10 +42,10 @@ export default class UserService extends APIService {
await this.api.delete(`/users/${id}`);
}
async createOneTimeAccessToken(userId: string) {
async createOneTimeAccessToken(userId: string, expiresAt: Date) {
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
userId,
expiresAt: new Date(Date.now() + 1000 * 60 * 5).toISOString()
expiresAt
});
return res.data.token;
}

View File

@@ -22,6 +22,7 @@
if ($userStore?.isAdmin) {
links = [
// svelte-ignore state_referenced_locally
...links,
{ href: '/settings/admin/users', label: 'Users' },
{ href: '/settings/admin/user-groups', label: 'User Groups' },

View File

@@ -58,7 +58,7 @@
Enable Self-Account Editing
</Label>
<p class="text-muted-foreground text-[0.8rem]">
Whether the user should be able to edit their own account details.
Whether the users should be able to edit their own account details.
</p>
</div>
</div>

View File

@@ -1,22 +1,51 @@
<script lang="ts">
import { page } from '$app/stores';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select/index.js';
import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util';
let {
oneTimeLink = $bindable()
userId = $bindable()
}: {
oneTimeLink: string | null;
userId: string | null;
} = $props();
const userService = new UserService();
let oneTimeLink: string | null = $state(null);
let selectedExpiration: keyof typeof availableExpirations = $state('1 hour');
let availableExpirations = {
'1 hour': 60 * 60,
'12 hours': 60 * 60 * 12,
'1 day': 60 * 60 * 24,
'1 week': 60 * 60 * 24 * 7,
'1 month': 60 * 60 * 24 * 30
};
async function createOneTimeAccessToken() {
try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
const token = await userService.createOneTimeAccessToken(userId!, expiration);
oneTimeLink = `${$page.url.origin}/login/${token}`;
} catch (e) {
axiosErrorToast(e);
}
}
function onOpenChange(open: boolean) {
if (!open) {
oneTimeLink = null;
userId = null;
}
}
</script>
<Dialog.Root open={!!oneTimeLink} {onOpenChange}>
<Dialog.Root open={!!userId} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>One Time Link</Dialog.Title>
@@ -25,9 +54,36 @@
have lost it.</Dialog.Description
>
</Dialog.Header>
<div>
<Label for="one-time-link">One Time Link</Label>
{#if oneTimeLink === null}
<div>
<Label for="expiration">Expiration</Label>
<Select.Root
selected={{
label: Object.keys(availableExpirations)[0],
value: Object.keys(availableExpirations)[0]
}}
onSelectedChange={(v) =>
(selectedExpiration = v!.value as keyof typeof availableExpirations)}
>
<Select.Trigger class="h-9 ">
<Select.Value>{selectedExpiration}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each Object.keys(availableExpirations) as key}
<Select.Item value={key}>{key}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Button
onclick={() => createOneTimeAccessToken()}
disabled={!selectedExpiration}
>
Generate Link
</Button>
{:else}
<Label for="one-time-link" class="sr-only">One Time Link</Label>
<Input id="one-time-link" value={oneTimeLink} readonly />
</div>
{/if}
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Badge } from '$lib/components/ui/badge/index';
import { Button } from '$lib/components/ui/button';
import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Table from '$lib/components/ui/table';
import UserService from '$lib/services/user-service';
@@ -21,7 +21,7 @@
users = initialUsers;
});
let oneTimeLink = $state<string | null>(null);
let userIdToCreateOneTimeLink: string | null = $state(null);;
const userService = new UserService();
@@ -48,15 +48,6 @@
}
});
}
async function createOneTimeAccessToken(userId: string) {
try {
const token = await userService.createOneTimeAccessToken(userId);
oneTimeLink = `${$page.url.origin}/login/${token}`;
} catch (e) {
axiosErrorToast(e);
}
}
</script>
<AdvancedTable
@@ -82,22 +73,20 @@
</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
<Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span>
</Button>
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
<Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item on:click={() => createOneTimeAccessToken(item.id)}
<DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)}
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
>
<DropdownMenu.Item href="/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
>
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
on:click={() => deleteUser(item)}
onclick={() => deleteUser(item)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
>
</DropdownMenu.Content>
@@ -106,4 +95,4 @@
{/snippet}
</AdvancedTable>
<OneTimeLinkModal {oneTimeLink} />
<OneTimeLinkModal userId={userIdToCreateOneTimeLink} />

View File

@@ -57,8 +57,13 @@ test('Create one time access token', async ({ page }) => {
.getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` })
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'One-time link' }).click();
await page.getByLabel('One Time Link').getByRole('combobox').click();
await page.getByRole('option', { name: '12 hours' }).click();
await page.getByRole('button', { name: 'Generate Link' }).click();
await expect(page.getByRole('textbox', { name: 'One Time Link' })).toHaveValue(
/http:\/\/localhost\/login\/.*/
);