hackatime/app/javascript/pages/OAuthApplications/Show.svelte
Mahad Kalam 5aabfe8fa7
UI fixes (#979)
* Small UI fixes

* OAuth2 applications tweaks
2026-02-18 09:02:13 +00:00

361 lines
11 KiB
Svelte

<script lang="ts">
import { Link } from "@inertiajs/svelte";
import Button from "../../components/Button.svelte";
import DestructiveActionModal from "./DestructiveActionModal.svelte";
import type { OAuthApplicationShowProps } from "./types";
let {
page_title,
heading,
subheading,
application,
secret,
labels,
confirmations,
}: OAuthApplicationShowProps = $props();
const csrfToken =
typeof document === "undefined"
? ""
: document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
let copiedValue = $state<string | null>(null);
let destructiveModalOpen = $state(false);
let pendingDestructiveAction = $state<"delete" | "rotate" | null>(null);
const copyToClipboard = async (value: string) => {
if (
typeof navigator !== "undefined" &&
navigator.clipboard &&
(typeof window === "undefined" || window.isSecureContext)
) {
await navigator.clipboard.writeText(value);
return;
}
if (typeof document === "undefined") {
throw new Error("Clipboard is not available");
}
const textArea = document.createElement("textarea");
textArea.value = value;
textArea.setAttribute("readonly", "");
textArea.style.position = "fixed";
textArea.style.opacity = "0";
textArea.style.pointerEvents = "none";
document.body.appendChild(textArea);
textArea.select();
textArea.setSelectionRange(0, textArea.value.length);
const successful = document.execCommand("copy");
document.body.removeChild(textArea);
if (!successful) {
throw new Error("Failed to copy text");
}
};
const copyValue = async (key: "uid" | "secret") => {
const value = key === "uid" ? application.uid : secret.value || "";
if (!value) return;
try {
await copyToClipboard(value);
copiedValue = key;
setTimeout(() => {
if (copiedValue === key) copiedValue = null;
}, 1500);
} catch (_error) {
copiedValue = null;
}
};
const openDestructiveModal = (action: "delete" | "rotate") => {
pendingDestructiveAction = action;
destructiveModalOpen = true;
};
const destructiveModalTitle = $derived.by(() => {
if (pendingDestructiveAction === "delete") {
return `Delete ${application.name}?`;
}
if (pendingDestructiveAction === "rotate") {
return "Rotate client secret?";
}
return "Confirm action";
});
const destructiveModalDescription = $derived.by(() => {
if (pendingDestructiveAction === "delete") {
return "This permanently deletes the OAuth application and breaks any integrations using it.";
}
if (pendingDestructiveAction === "rotate") {
return confirmations.rotate_secret;
}
return "";
});
const destructiveActionPath = $derived.by(() => {
if (pendingDestructiveAction === "delete") return application.destroy_path;
if (pendingDestructiveAction === "rotate") {
return application.rotate_secret_path;
}
return "";
});
const destructiveConfirmLabel = $derived.by(() => {
if (pendingDestructiveAction === "delete") return "Delete application";
if (pendingDestructiveAction === "rotate") return "Rotate secret";
return "Confirm";
});
const destructiveMethod = $derived.by(() =>
pendingDestructiveAction === "delete" ? "delete" : "post",
);
const destructiveConfirmStyle = $derived.by(() =>
pendingDestructiveAction === "delete" ? "danger" : "primary",
);
</script>
<svelte:head>
<title>{page_title}</title>
</svelte:head>
<div class="mx-auto max-w-6xl space-y-6">
<header>
<h1 class="text-3xl font-bold text-surface-content">{heading}</h1>
<p class="mt-1 text-sm text-muted">{subheading}</p>
</header>
<div class="grid gap-4 lg:grid-cols-[1fr_270px]">
<section class="space-y-4">
<article class="rounded-xl border border-surface-200 bg-dark p-5">
<h2 class="text-lg font-semibold text-surface-content">Credentials</h2>
<div class="mt-4 space-y-4">
<div>
<p class="mb-1 text-xs uppercase tracking-wide text-muted">
{labels.application_id}
</p>
<div class="flex flex-wrap items-center gap-2">
<code
class="min-w-0 flex-1 break-all rounded-md border border-surface-200 bg-darker px-3 py-2 font-mono text-xs text-surface-content"
>
{application.uid}
</code>
<Button
type="button"
variant="surface"
onclick={() => copyValue("uid")}
>{copiedValue === "uid" ? "Copied" : "Copy"}</Button
>
</div>
</div>
<div>
<p class="mb-1 text-xs uppercase tracking-wide text-muted">
{labels.secret}
</p>
{#if secret.hashed}
<div
class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
{labels.secret_hashed}
</div>
<p class="mt-2 text-xs text-yellow">
The secret is only shown once when the application is created.
</p>
{:else if secret.value}
<div class="flex flex-wrap items-center gap-2">
<code
class="min-w-0 flex-1 break-all rounded-md border border-surface-200 bg-darker px-3 py-2 font-mono text-xs text-surface-content"
>
{secret.value}
</code>
<Button
type="button"
variant="surface"
onclick={() => copyValue("secret")}
>
{copiedValue === "secret" ? "Copied" : "Copy"}
</Button>
</div>
{#if secret.just_rotated}
<p class="mt-2 text-xs text-green">
Here is your new secret. Store it now because it may not be
shown again.
</p>
{/if}
{/if}
</div>
<div>
<p class="mb-1 text-xs uppercase tracking-wide text-muted">
{labels.scopes}
</p>
{#if application.scopes.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each application.scopes as scope}
<span
class="rounded-md border border-primary/30 bg-primary/10 px-2 py-0.5 font-mono text-xs text-primary"
>
{scope}
</span>
{/each}
</div>
{:else}
<p class="text-sm text-muted">{labels.not_defined}</p>
{/if}
</div>
<div>
<p class="mb-1 text-xs uppercase tracking-wide text-muted">
{labels.confidential}
</p>
{#if application.confidential}
<span
class="inline-flex rounded-full border border-green/40 bg-green/15 px-2 py-0.5 text-xs font-semibold text-green"
>
Yes
</span>
{:else}
<span
class="inline-flex rounded-full border border-yellow/40 bg-yellow/15 px-2 py-0.5 text-xs font-semibold text-yellow"
>
No
</span>
{/if}
</div>
<div>
<p class="mb-1 text-xs uppercase tracking-wide text-muted">
Verified
</p>
{#if application.verified}
<span
class="inline-flex rounded-full border border-green/40 bg-green/15 px-2 py-0.5 text-xs font-semibold text-green"
>
Verified
</span>
{:else}
<span
class="inline-flex rounded-full border border-yellow/40 bg-yellow/15 px-2 py-0.5 text-xs font-semibold text-yellow"
>
Unverified
</span>
{/if}
</div>
</div>
</article>
<article class="rounded-xl border border-surface-200 bg-dark p-5">
<h2 class="text-lg font-semibold text-surface-content">
{labels.callback_urls}
</h2>
{#if application.redirect_uris.length > 0}
<div class="mt-4 space-y-2">
{#each application.redirect_uris as redirect}
<div
class="flex flex-wrap items-center gap-2 rounded-lg border border-surface-200 bg-darker/70 p-3"
>
<code
class="min-w-0 flex-1 break-all font-mono text-xs text-surface-content"
>
{redirect.value}
</code>
<a
href={redirect.authorize_path}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center rounded-lg border border-green bg-green px-3 py-2 text-xs font-semibold text-on-primary transition-opacity hover:opacity-90"
>
Test auth
</a>
</div>
{/each}
</div>
{:else}
<p class="mt-2 text-sm text-muted">{labels.not_defined}</p>
{/if}
</article>
</section>
<aside class="h-fit rounded-xl border border-surface-200 bg-dark p-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-muted">
{labels.actions}
</h2>
<div class="mt-3 space-y-2">
<Button href={application.edit_path} variant="primary" class="w-full"
>Edit application</Button
>
<Button
type="button"
variant="surface"
class="w-full border-red/45! bg-red/15! text-red! hover:bg-red/25!"
onclick={() => openDestructiveModal("delete")}
>
Delete application
</Button>
<Button
type="button"
variant="surface"
class="w-full border-red/45! bg-red/15! text-red! hover:bg-red/25!"
onclick={() => openDestructiveModal("rotate")}
>
Rotate secret
</Button>
{#if application.toggle_verified_path}
<form
method="post"
action={application.toggle_verified_path}
class="w-full"
>
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button
type="submit"
variant="surface"
class={`w-full ${application.verified ? "border-yellow/40! bg-yellow/40! text-yellow! hover:bg-yellow/60!" : "!border-green/45 bg-green/40! !text-green! hover:bg-green/60!"}`}
>
{application.verified
? "Remove verification"
: "Verify application"}
</Button>
</form>
{/if}
<Link
href={application.index_path}
class="inline-flex w-full items-center justify-center rounded-lg border border-surface-200 bg-surface-100 px-4 py-2 text-sm font-semibold text-surface-content transition-colors hover:bg-surface-200"
>
Back to applications
</Link>
</div>
</aside>
</div>
</div>
<DestructiveActionModal
bind:open={destructiveModalOpen}
title={destructiveModalTitle}
description={destructiveModalDescription}
actionPath={destructiveActionPath}
confirmLabel={destructiveConfirmLabel}
{csrfToken}
method={destructiveMethod}
confirmStyle={destructiveConfirmStyle}
/>