mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-15 17:23:23 +03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2587058ded | ||
|
|
ff06bf0b34 | ||
|
|
11ed661f86 | ||
|
|
29748cc6c7 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,3 +1,16 @@
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.8.1...v) (2024-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add environment variable to change the caddy port in Docker ([ff06bf0](https://github.com/stonith404/pocket-id/commit/ff06bf0b34496ce472ba6d3ebd4ea249f21c0ec3))
|
||||
* use improve table for users and audit logs ([11ed661](https://github.com/stonith404/pocket-id/commit/11ed661f86a512f78f66d604a10c1d47d39f2c39))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow copy to clipboard for client secret ([29748cc](https://github.com/stonith404/pocket-id/commit/29748cc6c7b7e5a6b54bfe837e0b1a98fa1ad594))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.8.0...v) (2024-10-11)
|
||||
|
||||
|
||||
|
||||
21
README.md
21
README.md
@@ -68,7 +68,7 @@ Required tools:
|
||||
cd ..
|
||||
pm2 start pocket-id-backend --name pocket-id-backend
|
||||
|
||||
# Optional: Download the GeoLite2 city database.
|
||||
# Optional: Download the GeoLite2 city database.
|
||||
# If not downloaded the ip location in the audit log will be empty.
|
||||
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||
|
||||
@@ -151,15 +151,16 @@ docker compose up -d
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default Value | Recommended to change | Description |
|
||||
| ---------------------- | ----------------------- | --------------------- | --------------------------------------------- |
|
||||
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
||||
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
|
||||
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
|
||||
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
||||
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
||||
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
||||
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
||||
| Variable | Default Value | Recommended to change | Description |
|
||||
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
|
||||
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
|
||||
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
|
||||
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
|
||||
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
|
||||
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
|
||||
| `PORT` | `3000` | no | The port on which the frontend should listen. |
|
||||
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
|
||||
|
||||
## Contribute
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
|
||||
|
||||
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
|
||||
group.POST("/oidc/authorize/new-client", jwtAuthMiddleware.Add(false), oc.authorizeNewClientHandler)
|
||||
group.POST("/oidc/token", oc.createIDTokenHandler)
|
||||
group.POST("/oidc/token", oc.createTokensHandler)
|
||||
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
||||
|
||||
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
|
||||
@@ -91,7 +91,7 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (oc *OidcController) createIDTokenHandler(c *gin.Context) {
|
||||
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
var input dto.OidcIdTokenDto
|
||||
|
||||
if err := c.ShouldBind(&input); err != nil {
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
let {
|
||||
items,
|
||||
selectedIds = $bindable(),
|
||||
withoutSearch = false,
|
||||
fetchItems,
|
||||
columns,
|
||||
rows
|
||||
}: {
|
||||
items: Paginated<T>;
|
||||
selectedIds?: string[];
|
||||
withoutSearch?: boolean;
|
||||
fetchItems: (search: string, page: number, limit: number) => Promise<Paginated<T>>;
|
||||
columns: (string | { label: string; hidden?: boolean })[];
|
||||
rows: Snippet<[{ item: T }]>;
|
||||
@@ -65,12 +67,14 @@
|
||||
</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>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
||||
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
||||
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
||||
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
|
||||
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`
|
||||
};
|
||||
|
||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||
@@ -95,10 +95,16 @@
|
||||
</div>
|
||||
<div class="mb-2 mt-1 flex items-center">
|
||||
<Label class="w-44">Client secret</Label>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||
>{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span
|
||||
>
|
||||
{#if !$clientSecretStore}
|
||||
{#if $clientSecretStore}
|
||||
<CopyToClipboard value={$clientSecretStore}>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||
{$clientSecretStore}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
{:else}
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||
>••••••••••••••••••••••••••••••••</span
|
||||
>
|
||||
<Button
|
||||
class="ml-2"
|
||||
onclick={createClientSecret}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
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 * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Pagination from '$lib/components/ui/pagination';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
||||
import type { Paginated } from '$lib/types/pagination.type';
|
||||
import type { User } from '$lib/types/user.type';
|
||||
import { debounced } from '$lib/utils/debounce-util';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
|
||||
import Ellipsis from 'lucide-svelte/icons/ellipsis';
|
||||
@@ -19,23 +17,17 @@
|
||||
|
||||
let { users: initialUsers }: { users: Paginated<User> } = $props();
|
||||
let users = $state<Paginated<User>>(initialUsers);
|
||||
let oneTimeLink = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
users = initialUsers;
|
||||
});
|
||||
|
||||
let oneTimeLink = $state<string | null>(null);
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
let pagination = $state<PaginationRequest>({
|
||||
page: 1,
|
||||
limit: 10
|
||||
});
|
||||
let search = $state('');
|
||||
|
||||
const debouncedSearch = debounced(async (searchValue: string) => {
|
||||
users = await userService.list(searchValue, pagination);
|
||||
}, 400);
|
||||
function fetchItems(search: string, page: number, limit: number) {
|
||||
return userService.list(search, { page, limit });
|
||||
}
|
||||
|
||||
async function deleteUser(user: User) {
|
||||
openConfirmDialog({
|
||||
@@ -47,7 +39,7 @@
|
||||
action: async () => {
|
||||
try {
|
||||
await userService.remove(user.id);
|
||||
users = await userService.list(search, pagination);
|
||||
users = await userService.list();
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
@@ -67,105 +59,51 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search users"
|
||||
bind:value={search}
|
||||
on:input={(e) => debouncedSearch((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="hidden md:table-cell">First name</Table.Head>
|
||||
<Table.Head class="hidden md:table-cell">Last name</Table.Head>
|
||||
<Table.Head>Email</Table.Head>
|
||||
<Table.Head>Username</Table.Head>
|
||||
<Table.Head class="hidden lg:table-cell">Role</Table.Head>
|
||||
<Table.Head>
|
||||
<span class="sr-only">Actions</span>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if users.data.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={6} class="text-center">No users found</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each users.data as user}
|
||||
<Table.Row>
|
||||
<Table.Cell class="hidden md:table-cell">{user.firstName}</Table.Cell>
|
||||
<Table.Cell class="hidden md:table-cell">{user.lastName}</Table.Cell>
|
||||
<Table.Cell>{user.email}</Table.Cell>
|
||||
<Table.Cell>{user.username}</Table.Cell>
|
||||
<Table.Cell class="hidden lg:table-cell">
|
||||
<Badge variant="outline">{user.isAdmin ? 'Admin' : 'User'}</Badge>
|
||||
</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>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item on:click={() => createOneTimeAccessToken(user.id)}
|
||||
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Item href="/settings/admin/users/{user.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(user)}
|
||||
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
{#if users?.data?.length ?? 0 > 0}
|
||||
<Pagination.Root
|
||||
class="mt-5"
|
||||
count={users.pagination.totalItems}
|
||||
perPage={pagination.limit}
|
||||
onPageChange={async (p) =>
|
||||
(users = await userService.list(search, {
|
||||
page: p,
|
||||
limit: pagination.limit
|
||||
}))}
|
||||
bind:page={users.pagination.currentPage}
|
||||
let:pages
|
||||
let:currentPage
|
||||
>
|
||||
<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.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={users.pagination.currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
</Pagination.Root>
|
||||
{/if}
|
||||
<AdvancedTable
|
||||
items={users}
|
||||
{fetchItems}
|
||||
columns={[
|
||||
'First name',
|
||||
'Last name',
|
||||
'Email',
|
||||
'Username',
|
||||
'Role',
|
||||
{ label: 'Actions', hidden: true }
|
||||
]}
|
||||
withoutSearch
|
||||
>
|
||||
{#snippet rows({ item })}
|
||||
<Table.Cell>{item.firstName}</Table.Cell>
|
||||
<Table.Cell>{item.lastName}</Table.Cell>
|
||||
<Table.Cell>{item.email}</Table.Cell>
|
||||
<Table.Cell>{item.username}</Table.Cell>
|
||||
<Table.Cell class="hidden lg:table-cell">
|
||||
<Badge variant="outline">{item.isAdmin ? 'Admin' : 'User'}</Badge>
|
||||
</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>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item on:click={() => createOneTimeAccessToken(item.id)}
|
||||
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
|
||||
>
|
||||
<DropdownMenu.Item href="/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)}
|
||||
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
|
||||
<OneTimeLinkModal {oneTimeLink} />
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
<script lang="ts">
|
||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as Pagination from '$lib/components/ui/pagination';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import AuditLogService from '$lib/services/audit-log-service';
|
||||
import type { AuditLog } from '$lib/types/audit-log.type';
|
||||
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
||||
import type { Paginated } from '$lib/types/pagination.type';
|
||||
|
||||
let { auditLogs: initialAuditLog }: { auditLogs: Paginated<AuditLog> } = $props();
|
||||
let auditLogs = $state<Paginated<AuditLog>>(initialAuditLog);
|
||||
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
let pagination = $state<PaginationRequest>({
|
||||
page: 1,
|
||||
limit: 15
|
||||
});
|
||||
async function fetchItems(search: string, page: number, limit: number) {
|
||||
return await auditLogService.list({
|
||||
page,
|
||||
limit
|
||||
});
|
||||
}
|
||||
|
||||
function toFriendlyEventString(event: string) {
|
||||
const words = event.split('_');
|
||||
@@ -25,73 +27,22 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Table.Root>
|
||||
<Table.Header class="whitespace-nowrap">
|
||||
<Table.Row>
|
||||
<Table.Head>Time</Table.Head>
|
||||
<Table.Head>Event</Table.Head>
|
||||
<Table.Head>Approximate Location</Table.Head>
|
||||
<Table.Head>IP Address</Table.Head>
|
||||
<Table.Head>Device</Table.Head>
|
||||
<Table.Head>Client</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body class="whitespace-nowrap">
|
||||
{#if auditLogs.data.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={6} class="text-center">No logs found</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each auditLogs.data as auditLog}
|
||||
<Table.Row>
|
||||
<Table.Cell>{new Date(auditLog.createdAt).toLocaleString()}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{toFriendlyEventString(auditLog.event)}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{auditLog.city && auditLog.country ? `${auditLog.city}, ${auditLog.country}` : 'Unknown'}</Table.Cell>
|
||||
<Table.Cell>{auditLog.ipAddress}</Table.Cell>
|
||||
<Table.Cell>{auditLog.device}</Table.Cell>
|
||||
<Table.Cell>{auditLog.data.clientName}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
{#if auditLogs?.data?.length ?? 0 > 0}
|
||||
<Pagination.Root
|
||||
class="mt-5"
|
||||
count={auditLogs.pagination.totalItems}
|
||||
perPage={pagination.limit}
|
||||
onPageChange={async (p) =>
|
||||
(auditLogs = await auditLogService.list({
|
||||
page: p,
|
||||
limit: pagination.limit
|
||||
}))}
|
||||
bind:page={auditLogs.pagination.currentPage}
|
||||
let:pages
|
||||
let:currentPage
|
||||
>
|
||||
<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.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={auditLogs.pagination.currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
</Pagination.Root>
|
||||
{/if}
|
||||
<AdvancedTable
|
||||
items={auditLogs}
|
||||
{fetchItems}
|
||||
columns={['Time', 'Event', 'Approximate Location', 'IP Address', 'Device', 'Client']}
|
||||
withoutSearch
|
||||
>
|
||||
{#snippet rows({ item })}
|
||||
<Table.Cell>{new Date(item.createdAt).toLocaleString()}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{toFriendlyEventString(item.event)}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
>{item.city && item.country ? `${item.city}, ${item.country}` : 'Unknown'}</Table.Cell
|
||||
>
|
||||
<Table.Cell>{item.ipAddress}</Table.Cell>
|
||||
<Table.Cell>{item.device}</Table.Cell>
|
||||
<Table.Cell>{item.data.clientName}</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
:80 {
|
||||
:{$CADDY_PORT:80} {
|
||||
reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080}
|
||||
reverse_proxy /.well-known/* http://localhost:{$BACKEND_PORT:8080}
|
||||
reverse_proxy /* http://localhost:{$PORT:3000}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
:80 {
|
||||
:{$CADDY_PORT:80} {
|
||||
reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080} {
|
||||
trusted_proxies 0.0.0.0/0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user