Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a0f28b5
service account tokens UI
royendo Mar 9, 2026
3d525df
ux pr review local
royendo Mar 9, 2026
d77bbb2
prettier, local code review, max height for modal
royendo Mar 9, 2026
2f5301e
fix nit UX issues
royendo Mar 9, 2026
f0f76b4
prettier
royendo Mar 9, 2026
5ea60c8
fixed issues
royendo Mar 10, 2026
2e74923
asreq
royendo Mar 18, 2026
85ba2b0
Merge remote-tracking branch 'origin/main' into feat-org-token-manage…
royendo Mar 18, 2026
514c273
prettier
royendo Mar 18, 2026
93ce010
asReq
royendo Mar 19, 2026
8c8c735
as req; + nit fixes
royendo Mar 24, 2026
98f19a6
compact messaging and UXQA
royendo Mar 24, 2026
c155935
as req
royendo Mar 31, 2026
61f65b1
Update tsc-with-whitelist.sh
royendo Mar 31, 2026
8c493fc
Merge remote-tracking branch 'origin/main' into feat-org-token-manage…
royendo Mar 31, 2026
95f856d
upgrade to svelte 5/vite
royendo Mar 31, 2026
14e53f9
prettier
royendo Mar 31, 2026
ca3139b
Merge branch 'main' into feat-org-token-management
royendo Apr 6, 2026
b8d063a
svelte5
royendo Apr 6, 2026
93170c1
Update +page.svelte
royendo Apr 6, 2026
7e46762
fix: code review fixes for service token management
royendo Apr 7, 2026
9c83cb7
test: add unit tests for service token `utils.ts`
royendo Apr 7, 2026
a1cb295
fix: add loading state to edit dialog, parallelize project role mutat…
royendo Apr 7, 2026
ded50d2
fix: add keys to mutable `{#each}` blocks in service token forms
royendo Apr 7, 2026
b8b6f82
following design of existing tables
royendo Apr 14, 2026
0bcbc18
Merge branch 'main' into feat-org-token-management
royendo Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/tsc-with-whitelist.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ web-admin/src/routes/[organization]/-/settings/+page.ts: error TS2307
web-admin/src/routes/[organization]/-/settings/billing/+page.ts: error TS2307
web-admin/src/routes/[organization]/-/settings/billing/payment/+page.ts: error TS2307
web-admin/src/routes/[organization]/-/settings/billing/upgrade/+page.ts: error TS2307
web-admin/src/routes/[organization]/-/settings/service-accounts/+page.ts: error TS2307
web-admin/src/routes/[organization]/-/settings/usage/+page.ts: error TS2307
web-admin/src/routes/[organization]/-/upgrade-callback/+page.ts: error TS2307
web-admin/src/routes/[organization]/[project]/-/open-query/+page.ts: error TS2307
Expand Down
233 changes: 233 additions & 0 deletions web-admin/src/features/service-tokens/CreateServiceDialog.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
<script lang="ts">
import { page } from "$app/stores";
import {
createAdminServiceCreateService,
createAdminServiceIssueServiceAuthToken,
createAdminServiceSetProjectMemberServiceRole,
createAdminServiceListProjectsForOrganization,
getAdminServiceListServicesQueryKey,
} from "@rilldata/web-admin/client";
import { Button } from "@rilldata/web-common/components/button";
import IconButton from "@rilldata/web-common/components/button/IconButton.svelte";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@rilldata/web-common/components/dialog";
import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus";
import { copyToClipboard } from "@rilldata/web-common/lib/actions/copy-to-clipboard";
import { useQueryClient } from "@tanstack/svelte-query";
import { isAxiosError } from "axios";
import { CopyIcon } from "lucide-svelte";
import { validateServiceName } from "./utils";
import ServiceForm from "./ServiceForm.svelte";

let { open = $bindable(false) }: { open: boolean } = $props();

let name = $state("");
let orgRole = $state("");
let projectAssignments: { project: string; role: string }[] = $state([]);
let attributes: { key: string; value: string }[] = $state([]);
let issuedToken = $state("");
let tokenCopied = $state(false);
let step: "form" | "token" = $state("form");

let organization = $derived($page.params.organization);
let projectsQuery = $derived(
createAdminServiceListProjectsForOrganization(organization),
);
let allProjects = $derived($projectsQuery.data?.projects ?? []);

let nameError = $derived(name ? validateServiceName(name) : "");
let hasAtLeastOneAssignment = $derived(
orgRole !== "" || projectAssignments.length > 0,
);
let isValid = $derived(
name.trim() !== "" && !nameError && hasAtLeastOneAssignment,
);

const queryClient = useQueryClient();
const createService = createAdminServiceCreateService();
const issueToken = createAdminServiceIssueServiceAuthToken();
const setProjectRole = createAdminServiceSetProjectMemberServiceRole();

function handleReset() {
name = "";
orgRole = "";
projectAssignments = [];
attributes = [];
issuedToken = "";
tokenCopied = false;
step = "form";
}

async function handleSubmit() {
try {
const firstProject = projectAssignments[0];
const attrObj = Object.fromEntries(
attributes
.filter((a) => a.key.trim())
.map((a) => [a.key.trim(), a.value]),
);

await $createService.mutateAsync({
org: organization,
data: {
name: name.trim(),
...(orgRole ? { orgRoleName: orgRole } : {}),
...(firstProject
? {
project: firstProject.project,
projectRoleName: firstProject.role,
}
: {}),
...(Object.keys(attrObj).length > 0 ? { attributes: attrObj } : {}),
},
});

// Assign remaining project roles in parallel; the first project is
// included in the create call above. These are not atomic: partial
// assignments are possible if one fails.
if (projectAssignments.length > 1) {
const results = await Promise.allSettled(
projectAssignments.slice(1).map((pa) =>
$setProjectRole.mutateAsync({
org: organization,
project: pa.project,
name: name.trim(),
data: { role: pa.role },
}),
),
);
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
console.warn(
`${failed.length} project role assignment(s) failed`,
failed,
);
}
}

const result = await $issueToken.mutateAsync({
org: organization,
serviceName: name.trim(),
data: {},
});

issuedToken = result.token ?? "";

await queryClient.invalidateQueries({
queryKey: getAdminServiceListServicesQueryKey(organization),
});

step = "token";
} catch (e) {
console.error("Error creating service", e);
eventBus.emit("notification", {
message: isAxiosError(e)
? (e.response?.data?.message ?? "Error creating service")
: "Error creating service",
type: "error",
});
}
}

function handleClose() {
open = false;
handleReset();
}
</script>

<Dialog
bind:open
onOpenChange={(isOpen) => {
if (!isOpen) handleReset();
}}
>
<DialogTrigger>
{#snippet child({ props })}
<div {...props} class="hidden"></div>
{/snippet}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{step === "form" ? "Create service" : "Service created"}
</DialogTitle>
</DialogHeader>

{#if step === "form"}
<DialogDescription>
Create a service account to access Rill programmatically.
</DialogDescription>
<ServiceForm
bind:name
bind:orgRole
bind:projectAssignments
bind:attributes
{nameError}
{allProjects}
formId="create-service-form"
showOptionalLabels
onSubmit={handleSubmit}
/>
<DialogFooter>
<Button type="tertiary" onClick={handleClose}>Cancel</Button>
<Button
type="primary"
form="create-service-form"
disabled={!isValid || $createService.isPending}
submitForm
>
Create
</Button>
</DialogFooter>
{:else}
<!-- Token display step -->
<div class="flex flex-col gap-y-4">
<p class="text-sm text-fg-tertiary">
Service <span class="font-medium text-fg-primary">{name}</span> has been
created. Copy the token below — it will not be shown again.
</p>
<div class="flex items-center gap-x-2">
<code
class="text-xs bg-surface-subtle border rounded px-2 py-2 flex-1 break-all select-all"
>
{issuedToken}
</code>
<IconButton
onclick={() => {
copyToClipboard(issuedToken);
tokenCopied = true;
}}
>
<CopyIcon size="14px" />
</IconButton>
</div>
<p class="text-xs text-fg-secondary">
This token will only be shown once. Make sure to copy it now.
</p>
</div>
<DialogFooter>
{#if tokenCopied}
<Button type="primary" onClick={handleClose}>Done</Button>
{:else}
<Button
type="primary"
onClick={() => {
copyToClipboard(issuedToken);
tokenCopied = true;
}}
>
<CopyIcon size="14px" />
Copy token
</Button>
{/if}
</DialogFooter>
{/if}
</DialogContent>
</Dialog>
86 changes: 86 additions & 0 deletions web-admin/src/features/service-tokens/DeleteServiceDialog.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<script lang="ts">
import { page } from "$app/stores";
import {
createAdminServiceDeleteService,
getAdminServiceListServicesQueryKey,
} from "@rilldata/web-admin/client";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@rilldata/web-common/components/alert-dialog";
import { Button } from "@rilldata/web-common/components/button";
import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus";
import { useQueryClient } from "@tanstack/svelte-query";

let {
open = $bindable(false),
name,
}: {
open: boolean;
name: string;
} = $props();

let organization = $derived($page.params.organization);

const queryClient = useQueryClient();
const deleteService = createAdminServiceDeleteService();

async function handleDelete() {
try {
await $deleteService.mutateAsync({ org: organization, name });

await queryClient.invalidateQueries({
queryKey: getAdminServiceListServicesQueryKey(organization),
});

eventBus.emit("notification", {
message: `Service "${name}" deleted`,
});

open = false;
} catch (error) {
console.error("Error deleting service", error);
eventBus.emit("notification", {
message: "Error deleting service",
type: "error",
});
}
}
</script>

<AlertDialog bind:open>
<AlertDialogTrigger>
{#snippet child({ props })}
<div {...props} class="hidden"></div>
{/snippet}
</AlertDialogTrigger>
<AlertDialogContent noCancel>
<AlertDialogHeader>
<AlertDialogTitle>Delete this service?</AlertDialogTitle>
<AlertDialogDescription>
<div class="mt-1">
The service <span class="font-medium">{name}</span> and all its tokens
will be permanently deleted.
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button
type="tertiary"
onClick={() => {
open = false;
}}>Cancel</Button
>
<Button
type="destructive"
onClick={handleDelete}
disabled={$deleteService.isPending}>Yes, delete</Button
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
Loading
Loading