mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 22:15:14 +00:00
361 lines
11 KiB
Svelte
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}
|
|
/>
|