Skip to content
This repository was archived by the owner on Apr 19, 2023. It is now read-only.

Commit e426608

Browse files
✨ Add group routes
1 parent dd66f28 commit e426608

File tree

11 files changed

+585
-5
lines changed

11 files changed

+585
-5
lines changed

src/api.ts

+32
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,35 @@ export const loginWithTokenResponse = async (auth: User["auth"]) => {
172172
);
173173
activeUserIndex.set(loggedInUsers.length - 1);
174174
};
175+
176+
export const refresh = async () => {
177+
try {
178+
const res = await fetch(`${BASE_URL}/auth/refresh`, {
179+
method: "POST",
180+
body: JSON.stringify({
181+
token: loggedInUsers[index].auth.refreshToken,
182+
}),
183+
headers: {
184+
"X-Requested-With": "XmlHttpRequest",
185+
Accept: "application/json",
186+
"Content-Type": "application/json",
187+
},
188+
});
189+
const {
190+
accessToken,
191+
refreshToken,
192+
}: { accessToken: string; refreshToken: string } = await res.json();
193+
users.update((val) =>
194+
val.map((user, i) => {
195+
if (index === i) {
196+
return {
197+
details: user.details,
198+
memberships: user.memberships,
199+
auth: { accessToken, refreshToken },
200+
};
201+
}
202+
return user;
203+
})
204+
);
205+
} catch (error) {}
206+
};

src/components/Table/GroupRecord.svelte

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</script>
1111

1212
<a
13-
href={`/groups/${data.id}/summary`}
13+
href={`/groups/${data.id}/details`}
1414
class="inline-flex items-center transition motion-reduce:transition-none hover:opacity-50">
1515
<div class="flex-shrink-0 h-10 w-10">
1616
<img

src/routes/admin/audit-logs.svelte

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
class="text-indigo-600"
5252
aria-label={item.group ? item.group.name : undefined}
5353
data-balloon-pos={item.group ? 'up' : undefined}
54-
href={`/groups/${item.groupId}/summary`}>#{item.groupId}</a>
54+
href={`/groups/${item.groupId}/details`}>#{item.groupId}</a>
5555
</div>
5656
{/if}
5757
</div>
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script lang="ts">
2+
import { stores } from "@sapper/app";
3+
4+
const { page } = stores();
5+
const { slug } = $page.params;
6+
const nav = [
7+
{ title: "Details", slug: "details" },
8+
{ title: "Members", slug: "members" },
9+
{ title: "Security", slug: "security" },
10+
{ title: "API keys", slug: "api-keys" },
11+
{ title: "Audit logs", slug: "audit-logs" },
12+
];
13+
</script>
14+
15+
<div class="max-w-7xl my-10 mx-auto px-4 sm:px-6 lg:px-8">
16+
<div class="md:grid md:grid-cols-4 md:gap-6">
17+
<div class="md:col-span-1">
18+
<div class="shadow bg-white sm:rounded-md overflow-hidden top-5 sticky">
19+
{#each nav as item}
20+
<a
21+
href={`/groups/${slug}/${item.slug}`}
22+
class={$page.path === `/groups/${slug}/${item.slug}` ? 'block px-4 py-3 text-sm transition motion-reduce:transition-none text-gray-700 bg-gray-200 font-bold focus:outline-none focus:ring-0 focus:text-gray-900 focus:bg-gray-300' : 'block px-4 py-3 text-sm transition motion-reduce:transition-none text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:bg-gray-100 focus:text-gray-900 focus:outline-none focus:ring-0'}
23+
role="menuitem">{item.title}</a>
24+
{/each}
25+
</div>
26+
</div>
27+
<div class="md:mt-0 md:col-span-3 shadow bg-white sm:rounded-md overflow-hidden">
28+
<slot />
29+
</div>
30+
</div>
31+
</div>
+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<script lang="ts">
2+
import { stores } from "@sapper/app";
3+
import type { ApiKey } from "@koj/types";
4+
import { api, can } from "../../../api";
5+
import DataTable from "../../../components/DataTable.svelte";
6+
import DeleteModal from "../../../components/DeleteModal.svelte";
7+
import { onMount } from "svelte";
8+
import Form from "../../../components/Form.svelte";
9+
import DeleteButton from "../../../components/Table/DeleteButton.svelte";
10+
import EditButton from "../../../components/Table/EditButton.svelte";
11+
import Modal from "../../../components/Modal.svelte";
12+
13+
onMount(async () => {
14+
scopes = await api<Record<string, string>>({
15+
method: "GET",
16+
url: `/groups/${slug}/api-keys/scopes`,
17+
});
18+
});
19+
20+
const { page } = stores();
21+
const { slug } = $page.params;
22+
const primaryKeyType = "id";
23+
let data: ApiKey[] = [];
24+
let scopes: Record<string, string> = {};
25+
let editActive: ApiKey | undefined = undefined;
26+
let deleteActiveKey: number | undefined = undefined;
27+
let showActiveKeys: number[] = [];
28+
29+
const updateData = (item: ApiKey) => {
30+
if (data.find((i) => i[primaryKeyType] === item[primaryKeyType]))
31+
data = data.map((i) => {
32+
if (i[primaryKeyType] === item[primaryKeyType]) return item;
33+
return i;
34+
});
35+
else data = [...data, item];
36+
};
37+
38+
const add = async (body: { name: string; scopes: string[] }) => {
39+
const result = await api<ApiKey>({
40+
method: "POST",
41+
url: `/groups/${slug}/api-keys`,
42+
body,
43+
});
44+
updateData(result);
45+
};
46+
47+
const edit = async (body: { name: string; scopes: string[] }) => {
48+
if (!editActive) return;
49+
const result = await api<ApiKey>({
50+
method: "PATCH",
51+
url: `/groups/${slug}/api-keys/${editActive[primaryKeyType]}`,
52+
body,
53+
});
54+
updateData(result);
55+
editActive = undefined;
56+
};
57+
58+
const toggleActive = (id: number) => {
59+
if (showActiveKeys.includes(id)) showActiveKeys = showActiveKeys.filter((i) => i !== id);
60+
else showActiveKeys = [...showActiveKeys, id];
61+
};
62+
</script>
63+
64+
<svelte:head>
65+
<title>API keys</title>
66+
</svelte:head>
67+
68+
<DataTable
69+
let:item
70+
{data}
71+
title="API keys"
72+
itemName="API keys"
73+
titleKey="apiKey"
74+
text="API keys are used to programmatically access features using the API."
75+
endpoint={`/groups/${slug}/api-keys`}
76+
headers={['API key', 'Scopes', 'Restrictions']}
77+
onData={(val) => (data = val)}
78+
{primaryKeyType}
79+
filters={[{ title: 'ID', name: 'id', type: 'int' }, { title: 'Created at', name: 'createdAt', type: 'datetime' }, { title: 'Updated at', name: 'updatedAt', type: 'datetime' }]}>
80+
{#if !showActiveKeys.includes(item.id)}
81+
<td class="px-7 py-4 whitespace-nowrap text-sm">{item.name}</td>
82+
{:else}
83+
<td class="px-7 py-4 whitespace-nowrap text-sm">
84+
<code class="rounded border">{item.apiKey}</code>
85+
</td>
86+
{/if}
87+
<td class="px-7 py-4 whitespace-nowrap text-sm text-gray-500">{item.scopes.length}</td>
88+
<td class="px-7 py-4 whitespace-nowrap text-sm text-gray-500">
89+
{(item.ipRestrictions || []).length + (item.referrerRestrictions || []).length}
90+
</td>
91+
<td class="px-7 py-4 whitespace-nowrap text-right text-sm font-medium">
92+
<button
93+
aria-label={showActiveKeys.includes(item.id) ? 'Hide API key' : 'Show API key'}
94+
data-balloon-pos="up"
95+
class="text-gray-500 hover:text-indigo-700 transition motion-reduce:transition-none ml-2 align-middle focus:text-indigo-700"
96+
on:click={() => toggleActive(item.id)}>
97+
{#if showActiveKeys.includes(item.id)}
98+
<svg
99+
class="w-5 h-5"
100+
fill="none"
101+
stroke="currentColor"
102+
viewBox="0 0 24 24"
103+
xmlns="http://www.w3.org/2000/svg"><path
104+
stroke-linecap="round"
105+
stroke-linejoin="round"
106+
stroke-width="2"
107+
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
108+
{:else}
109+
<svg
110+
class="w-5 h-5"
111+
fill="none"
112+
stroke="currentColor"
113+
viewBox="0 0 24 24"
114+
xmlns="http://www.w3.org/2000/svg"><path
115+
stroke-linecap="round"
116+
stroke-linejoin="round"
117+
stroke-width="2"
118+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
119+
<path
120+
stroke-linecap="round"
121+
stroke-linejoin="round"
122+
stroke-width="2"
123+
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
124+
{/if}
125+
</button>
126+
{#if can(`api-key:write-info-${item[primaryKeyType]}`)}
127+
<EditButton on:click={() => (editActive = item)} />
128+
{/if}
129+
{#if can(`api-key:write-info-${item[primaryKeyType]}`)}
130+
<DeleteButton on:click={() => (deleteActiveKey = item[primaryKeyType])} />
131+
{/if}
132+
</td>
133+
</DataTable>
134+
135+
<div class="p-7">
136+
<Form
137+
title="Add API key"
138+
text="You can create another API key with specific permissions. You can add IP address range and referrer restrictions after creating the API key."
139+
items={[{ name: 'name', label: 'Name', required: true }, { name: 'scopes', label: 'Scopes', type: 'multiple', options: scopes, required: true }]}
140+
onSubmit={add}
141+
submitText="Add API key" />
142+
</div>
143+
144+
{#if deleteActiveKey}
145+
<DeleteModal
146+
title="Delete API key"
147+
text="Are you sure you want to permanently delete this API key? It will stop working immediately."
148+
onClose={() => (deleteActiveKey = undefined)}
149+
url={`/groups/${slug}/api-keys/${deleteActiveKey}`}
150+
onSuccess={() => {
151+
data = data.filter((i) => i[primaryKeyType] !== deleteActiveKey);
152+
deleteActiveKey = undefined;
153+
}} />
154+
{/if}
155+
156+
{#if editActive}
157+
<Modal onClose={() => (editActive = undefined)}>
158+
<Form
159+
title="Edit API key"
160+
items={[{ name: 'name', label: 'Name', required: true }, { name: 'description', label: 'Description', type: 'textarea' }, { name: 'scopes', label: 'Scopes', type: 'multiple', options: scopes, required: true }, { name: 'ipRestrictions', label: 'IP address restrictions', type: 'array', hint: 'Enter a comma-separated list of IP CIDRs' }, { name: 'referrerRestrictions', label: 'Referrer restrictions', type: 'array', hint: 'Enter a comma-separated list of hostnames' }]}
161+
values={editActive}
162+
onSubmit={edit}
163+
submitText="Save API key"
164+
onClose={() => (editActive = undefined)}
165+
modal={true} />
166+
</Modal>
167+
{/if}
+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<script lang="ts">
2+
import { stores } from "@sapper/app";
3+
import type { AuditLog } from "@koj/types";
4+
import { saveAs } from "file-saver";
5+
import DataTable from "../../../components/DataTable.svelte";
6+
import LocationAgentIcons from "../../../components/LocationAgentIcons.svelte";
7+
import UserRecord from "../../../components/Table/UserRecord.svelte";
8+
import DownloadButton from "../../../components/Table/DownloadButton.svelte";
9+
import TimeAgo from "../../../components/TimeAgo.svelte";
10+
11+
const { page } = stores();
12+
const { slug } = $page.params;
13+
const primaryKeyType = "id";
14+
let data: AuditLog[] = [];
15+
16+
const download = (record: AuditLog) =>
17+
saveAs(new Blob([JSON.stringify(record, null, 2)]), `audit-log-${record.id}.json`);
18+
</script>
19+
20+
<svelte:head>
21+
<title>Audit logs</title>
22+
</svelte:head>
23+
24+
<DataTable
25+
let:item
26+
{data}
27+
title="Audit logs"
28+
titleKey="event"
29+
itemName="audit logs"
30+
text="These audit logs offer detailed insights into user-made changes. Audit logs are auto-deleted after 90 days."
31+
endpoint={`/groups/${slug}/audit-logs`}
32+
headers={['Date', 'User', 'Event', 'Device']}
33+
onData={(val) => (data = val)}
34+
{primaryKeyType}
35+
filters={[{ name: primaryKeyType, title: 'ID', type: 'int' }, { name: 'event', title: 'Event', type: 'string' }, { name: 'rawEvent', title: 'Event type', type: 'string' }, { name: 'groupId', title: 'Group ID', type: 'int' }, { name: 'userId', title: 'User ID', type: 'int' }, { name: 'ipAddress', title: 'IP address', type: 'string' }, { name: 'userAgent', title: 'User agent', type: 'string' }, { name: 'browser', title: 'Browser', type: 'string' }, { name: 'operatingSystem', title: 'Operating system', type: 'string' }, { name: 'city', title: 'City', type: 'string' }, { name: 'region', title: 'Region', type: 'string' }, { name: 'countryCode', title: 'Country code', type: 'string' }, { name: 'createdAt', title: 'Created at', type: 'datetime' }, { name: 'updatedAt', title: 'Updated at', type: 'datetime' }]}>
36+
<td class="px-7 py-4 whitespace-nowrap text-sm text-gray-500">
37+
<TimeAgo date={item.createdAt} />
38+
</td>
39+
<td class="px-7 py-4 whitespace-nowrap text-sm">
40+
{#if item.user}
41+
<UserRecord item={item.user} iconOnly={true} />
42+
{/if}
43+
</td>
44+
<td class="px-7 py-4 whitespace-nowrap text-sm">
45+
<div class="font-medium">
46+
<a
47+
href={`/admin/audit-logs?q=${encodeURIComponent(`event: ${item.event}`)}`}><code>{item.event}</code></a>
48+
</div>
49+
<div class="text-gray-500 mt-1">
50+
{#if item.groupId}
51+
<div>
52+
Group
53+
<a
54+
class="text-indigo-600"
55+
aria-label={item.group ? item.group.name : undefined}
56+
data-balloon-pos={item.group ? 'up' : undefined}
57+
href={`/groups/${item.groupId}/details`}>#{item.groupId}</a>
58+
</div>
59+
{/if}
60+
</div>
61+
</td>
62+
<td class="px-7 py-4 whitespace-nowrap text-sm">
63+
<LocationAgentIcons
64+
browserHref={`/admin/audit-logs?q=${encodeURIComponent(`browser: contains ${(item.browser || '').split(' ')[0]}`)}`}
65+
operatingSystemHref={`/admin/audit-logs?q=${encodeURIComponent(`operatingSystem: contains ${(item.operatingSystem || '').split(' ')[0]}`)}`}
66+
countryCodeHref={`/admin/audit-logs?q=${encodeURIComponent(`countryCode: ${item.countryCode}`)}`}
67+
{item} />
68+
</td>
69+
<td class="px-7 py-4 whitespace-nowrap text-right">
70+
<DownloadButton on:click={() => download(item)} />
71+
</td>
72+
</DataTable>
+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<script lang="ts">
2+
import { stores } from "@sapper/app";
3+
import { onMount } from "svelte";
4+
import { api } from "../../../api";
5+
import Error from "../../../components/Error.svelte";
6+
import Form from "../../../components/Form.svelte";
7+
import { activeNotification } from "../../../stores";
8+
9+
const { page } = stores();
10+
const { slug } = $page.params;
11+
let state = "ready";
12+
let error = "";
13+
let data: any = {};
14+
15+
onMount(() => {
16+
fetch();
17+
});
18+
19+
const fetch = async () => {
20+
state = "fetching";
21+
try {
22+
data = await api<any>({
23+
method: "GET",
24+
url: `/groups/${slug}`,
25+
onCachedResponse: (result) => (data = result),
26+
});
27+
error = "";
28+
} catch (err) {
29+
error = err.message;
30+
}
31+
state = "ready";
32+
};
33+
34+
const edit = async (body: { name: string }) => {
35+
data = await api<any>({
36+
method: "PATCH",
37+
url: `/groups/${slug}`,
38+
body,
39+
});
40+
error = "";
41+
activeNotification.set({
42+
text: "Group has been updated",
43+
type: "success",
44+
});
45+
};
46+
</script>
47+
48+
<svelte:head>
49+
<title>Details</title>
50+
</svelte:head>
51+
52+
<div class="p-7">
53+
<h1 class="text-2xl">Details</h1>
54+
{#if error}
55+
<Error {error} />
56+
{/if}
57+
{#if state === 'fetching'}
58+
<div class={`loading ${data ? 'loading-has' : 'h-52 bg-gray-50'}`} />
59+
{/if}
60+
<Form
61+
items={[{ name: 'name', label: 'Name', required: true }]}
62+
values={data}
63+
submitText="Save security settings"
64+
onSubmit={edit} />
65+
</div>

0 commit comments

Comments
 (0)