Skip to content

Commit

Permalink
Add token creation/list/revocation DB, API and UI
Browse files Browse the repository at this point in the history
  • Loading branch information
vbustamante authored and jkeiser committed Dec 20, 2024
1 parent ea97b19 commit 7f58169
Show file tree
Hide file tree
Showing 22 changed files with 1,932 additions and 228 deletions.
1 change: 1 addition & 0 deletions app/auth-portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"dependencies": {
"@si/ts-lib": "workspace:*",
"@si/vue-lib": "workspace:*",
"@vueuse/core": "^12.0.0",
"@vueuse/head": "^1.3.1",
"axios": "^1.7.7",
"clsx": "^1.2.1",
Expand Down
62 changes: 62 additions & 0 deletions app/auth-portal/src/components/AuthTokenListItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<template>
<tr
class="children:px-md children:py-sm children:truncate text-sm font-medium text-gray-800 dark:text-gray-200"
>
<td class="">
<div
class="xl:max-w-[800px] lg:max-w-[60vw] md:max-w-[50vw] sm:max-w-[40vw] max-w-[150px] truncate"
>
{{ authToken.name }}
</div>
</td>
<!-- TODO show user if it's not current user -->
<td class="normal-case">{{ authToken.createdAt }}</td>
<td class="normal-case">{{ authToken.expiresAt }}</td>
<td class="normal-case">
<ErrorMessage
v-if="revoke.error.value"
:error="revoke.error.value as Error"
/>
<VButton
v-if="workspace.role === 'OWNER'"
icon="trash"
:loading="revoke.isLoading.value"
class="cursor-pointer hover:text-destructive-500"
@click="revoke.execute()"
/>
</td>
</tr>
</template>

<script lang="ts" setup>
import { ErrorMessage, VButton } from "@si/vue-lib/design-system";
import { useAsyncState } from "@vueuse/core";
import { AuthTokenId, Workspace } from "@/store/workspaces.store";
import { AuthToken } from "@/store/authTokens.store";
const props = defineProps<{
authToken: AuthToken;
workspace: Readonly<Workspace>;
}>();
const emit = defineEmits<{
(e: "revoke", id: AuthTokenId): Promise<unknown>;
(e: "rename", id: AuthTokenId, name: string): Promise<unknown>;
}>();
/** Action to revoke token */
const revoke = useAsyncState(
async () => {
await emit("revoke", props.authToken.id);
},
undefined,
{ immediate: false },
);
// /** Action to rename */
// const rename = useAsyncState(async () => {
// await emit("rename", name.value);
// }, undefined);
// /** Name of token to create */
// const name = ref(authToken.name);
</script>
43 changes: 43 additions & 0 deletions app/auth-portal/src/components/WorkspacePageHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<template>
<div class="flex flex-row gap-sm items-center mb-md">
<div class="flex flex-col gap-2xs grow">
<div
ref="titleDiv"
v-tooltip="titleTooltip"
class="text-lg font-bold line-clamp-3 break-words"
>
{{ title }}
</div>
<div>{{ subtitle }}</div>
</div>
<slot />
<RouterLink :to="{ name: 'workspaces' }">
<VButton label="Return To Workspaces" tone="neutral" />
</RouterLink>
</div>
</template>

<script lang="ts" setup>
import { VButton } from "@si/vue-lib/design-system";
import { computed, ref } from "vue";
const props = defineProps<{
title: string;
subtitle: string;
}>();
const titleDiv = ref<HTMLElement>();
const titleTooltip = computed(() => {
if (
titleDiv.value &&
titleDiv.value.scrollHeight > titleDiv.value.offsetHeight
) {
return {
content: props.title,
delay: { show: 700, hide: 10 },
};
} else {
return {};
}
});
</script>
207 changes: 207 additions & 0 deletions app/auth-portal/src/pages/WorkspaceAuthTokensPage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<template>
<LoadStatus :status="workspaceStatus">
<template #uninitialized>
<div>You must log in to view workspace status.</div>
</template>
<template #success>
<div class="overflow-hidden">
<WorkspacePageHeader
:title="`${workspace.displayName} > API Tokens`"
subtitle="From here you can manage API tokens for your workspace. Enter the name and expiration of the API token below and click the Generate API Token button"
>
<RouterLink
:to="{ name: 'workspace-settings', params: { workspaceId } }"
>
<Icon
name="settings"
tooltip="Settings"
tooltipPlacement="top"
size="lg"
class="flex-none"
iconTone="warning"
iconIdleTone="shade"
iconBgActiveTone="action"
/>
</RouterLink>
</WorkspacePageHeader>

<Stack>
<ErrorMessage
:message="(createAuthToken.error.value as Error)?.message"
/>
<div class="flex flex-row flex-wrap items-end gap-xs">
<VormInput
v-model="createAuthTokenName"
:disabled="workspace.role !== 'OWNER'"
:required="false"
label="Token Name"
placeholder="My Automation Token"
/>
<VButton
:disabled="workspace.role !== 'OWNER'"
:loading="createAuthToken.isLoading.value"
loadingText="Creating ..."
iconRight="chevron--down"
tone="action"
variant="solid"
@click="createAuthToken.execute()"
>
Create Automation Token
</VButton>
</div>
<Stack
v-if="createAuthToken.state.value"
class="border-2 outline-2 rounded p-8"
>
<div>Token successfully created!</div>
<div class="text-lg font-bold">
This is the last time you will ever see this token value.
</div>
<VormInput
:modelValue="createAuthToken.state.value"
type="textarea"
label="Your API Token"
disabled
/>
<div>
Click the button below to copy it before navigating away or doing
anything else!
</div>
<VButton
icon="clipboard-copy"
tone="action"
label="Copy API Token To Clipboard"
class="mt-xs"
clickSuccess
successText="Copied to clipboard!"
@click="copyToken"
/>
</Stack>
<ErrorMessage :message="(authTokens.error.value as Error)?.message" />
<div v-if="authTokens.state.value" class="relative">
<Stack>
<table
class="w-full divide-y divide-neutral-400 dark:divide-neutral-600 border-b border-neutral-400 dark:border-neutral-600"
>
<thead>
<tr
class="children:pb-xs children:px-md children:font-bold text-left text-xs uppercase"
>
<th scope="col">Name</th>
<th scope="col">Created</th>
<th scope="col">Expires</th>
<th scope="col">Revoke</th>
</tr>
</thead>
<tbody
class="divide-y divide-neutral-300 dark:divide-neutral-700"
>
<AuthTokenListItem
v-for="authToken of Object.values(
authTokens.state.value,
).reverse()"
:key="authToken.id"
:authToken="authToken"
:workspace="workspace"
@revoke="revokeAuthToken"
@rename="renameAuthToken"
/>
</tbody>
</table>
</Stack>
</div>
</Stack>
</div>
</template>
</LoadStatus>
</template>

<script lang="ts" setup>
import * as _ from "lodash-es";
import { computed, ref } from "vue";
import {
Icon,
VormInput,
Stack,
VButton,
LoadStatus,
ErrorMessage,
} from "@si/vue-lib/design-system";
import { useAsyncState } from "@vueuse/core";
import { ApiRequest } from "@si/vue-lib/pinia";
import {
AuthTokenId,
useWorkspacesStore,
WorkspaceId,
} from "@/store/workspaces.store";
import WorkspacePageHeader from "@/components/WorkspacePageHeader.vue";
import { useAuthTokensApi } from "@/store/authTokens.store";
import AuthTokenListItem from "@/components/AuthTokenListItem.vue";
const workspacesStore = useWorkspacesStore();
const api = useAuthTokensApi();
const props = defineProps<{
workspaceId: WorkspaceId;
}>();
// Fetch the workspace (by fetching all workspaces)
const workspaceStatus = workspacesStore.refreshWorkspaces();
const workspace = computed(
() => workspacesStore.workspacesById[props.workspaceId],
);
/** The list of tokens */
const authTokens = useAsyncState(
async () => {
const { authTokens } = await apiData(
api.FETCH_AUTH_TOKENS(props.workspaceId),
);
return _.keyBy(authTokens, "id");
},
undefined,
{ shallow: false },
);
/** Action to create auth token. Sets .state when done. */
const createAuthToken = useAsyncState(
async () => {
const { authToken, token } = await apiData(
api.CREATE_AUTH_TOKEN(props.workspaceId, createAuthTokenName.value),
);
if (authTokens.state.value) {
authTokens.state.value[authToken.id] = authToken;
}
return token;
},
undefined,
{ immediate: false, resetOnExecute: false },
);
/** Name of token to create */
const createAuthTokenName = ref("");
async function revokeAuthToken(tokenId: AuthTokenId) {
await api.REVOKE_AUTH_TOKEN(props.workspaceId, tokenId);
if (authTokens.state.value) {
delete authTokens.state.value[tokenId];
}
}
async function renameAuthToken(tokenId: AuthTokenId, name: string) {
await api.RENAME_AUTH_TOKEN(props.workspaceId, tokenId, name);
if (authTokens.state.value) {
authTokens.state.value[tokenId].name = name;
}
}
async function copyToken() {
if (createAuthToken.state.value)
await navigator.clipboard.writeText(createAuthToken.state.value);
}
async function apiData<T>(request: Promise<ApiRequest<T>>) {
const { result } = await request;
if (!result.success) throw result.err;
return result.data;
}
</script>
Loading

0 comments on commit 7f58169

Please sign in to comment.