OAuth2 apps inertia'd! (#966)

* OAuth2 apps Inertia'd!

* Rose Pine/Rose Pine Dawn themes!

* Run formatting pass

* add some tests?
This commit is contained in:
Mahad Kalam 2026-02-17 13:45:44 +00:00 committed by GitHub
parent 8adf2a7fce
commit ef94a9da9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1460 additions and 31 deletions

View file

@ -253,6 +253,66 @@ html[data-theme="nord"] {
--color-danger: #bf616a;
}
html[data-theme="rose"] {
--app-color-scheme: dark;
--color-on-primary: #191724;
--color-darker: #191724;
--color-dark: #1f1d2e;
--color-darkless: #26233a;
--color-red: #eb6f92;
--color-orange: #f6c177;
--color-yellow: #f6c177;
--color-green: #31748f;
--color-cyan: #9ccfd8;
--color-blue: #c4a7e7;
--color-purple: #c4a7e7;
--color-primary: #eb6f92;
--color-secondary: #908caa;
--color-muted: #908caa;
--color-text-muted: #908caa;
--color-surface: #1f1d2e;
--color-surface-100: #26233a;
--color-surface-200: #312f44;
--color-surface-300: #3d3b52;
--color-surface-content: #e0def4;
--color-info: #9ccfd8;
--color-success: #31748f;
--color-warning: #f6c177;
--color-danger: #eb6f92;
}
html[data-theme="rose_pine_dawn"] {
--app-color-scheme: light;
--color-on-primary: #fffaf3;
--color-darker: #dfdad9;
--color-dark: #f2e9e1;
--color-darkless: #cecacd;
--color-red: #aa586f;
--color-orange: #a35a00;
--color-yellow: #a35a00;
--color-green: #286983;
--color-cyan: #56949f;
--color-blue: #907aa9;
--color-purple: #907aa9;
--color-primary: #aa586f;
--color-secondary: #5e5977;
--color-muted: #5e5977;
--color-text-muted: #5e5977;
--color-surface: #faf4ed;
--color-surface-100: #f2e9e1;
--color-surface-200: #c3bbb8;
--color-surface-300: #cecacd;
--color-surface-content: #575279;
--color-info: #56949f;
--color-success: #286983;
--color-warning: #a35a00;
--color-danger: #aa586f;
}
html[data-theme="rose_pine_dawn"] body {
background-color: #FCF2E9;
}
.project-toggle-group {
@apply flex items-center gap-2 rounded-lg p-1;
background-color: var(--color-darkless);

View file

@ -246,6 +246,46 @@ select {
animation: spin 1s linear infinite;
}
.activity-cell--0 {
background-color: color-mix(
in oklab,
var(--color-surface-content) 12%,
var(--color-surface)
);
}
.activity-cell--1 {
background-color: color-mix(
in oklab,
var(--color-success) 35%,
var(--color-surface)
);
}
.activity-cell--2 {
background-color: color-mix(
in oklab,
var(--color-success) 50%,
var(--color-surface)
);
}
.activity-cell--3 {
background-color: color-mix(
in oklab,
var(--color-success) 68%,
var(--color-surface)
);
}
.activity-cell--4 {
background-color: color-mix(
in oklab,
var(--color-success) 85%,
var(--color-surface)
);
}
.super {
@apply italic text-sm text-[#ccc] mt-0 mb-0.5 mx-0;
}

View file

@ -1,30 +1,41 @@
# frozen_string_literal: true
module Doorkeeper
class ApplicationsController < Doorkeeper::ApplicationController
layout "doorkeeper/admin" unless Doorkeeper.configuration.api_only
class ApplicationsController < InertiaController
layout "inertia"
before_action :authenticate_admin!
before_action :authenticate_oauth_owner!
before_action :set_application, only: %i[show edit update destroy rotate_secret]
def index
@applications = current_resource_owner.oauth_applications.ordered_by(:created_at)
respond_to do |format|
format.html
format.html do
render inertia: "OAuthApplications/Index", props: index_props
end
format.json { head :no_content }
end
end
def show
respond_to do |format|
format.html
format.html do
render inertia: "OAuthApplications/Show", props: show_props
end
format.json { render json: @application, as_owner: true }
end
end
def new
@application = Doorkeeper.config.application_model.new
render inertia: "OAuthApplications/New", props: form_props(
heading: I18n.t("doorkeeper.applications.new.title"),
subheading: "Create a new OAuth application to integrate with Hackatime.",
submit_path: oauth_applications_path,
form_method: "post"
)
end
def create
@ -41,7 +52,14 @@ module Doorkeeper
end
else
respond_to do |format|
format.html { render :new }
format.html do
render inertia: "OAuthApplications/New", props: form_props(
heading: I18n.t("doorkeeper.applications.new.title"),
subheading: "Create a new OAuth application to integrate with Hackatime.",
submit_path: oauth_applications_path,
form_method: "post"
), status: :unprocessable_entity
end
format.json do
errors = @application.errors.full_messages
render json: { errors: errors }, status: :unprocessable_entity
@ -50,7 +68,14 @@ module Doorkeeper
end
end
def edit; end
def edit
render inertia: "OAuthApplications/Edit", props: form_props(
heading: I18n.t("doorkeeper.applications.edit.title"),
subheading: "Update the settings for #{@application.name}.",
submit_path: oauth_application_path(@application),
form_method: "patch"
)
end
def update
if @application.update(application_params)
@ -62,7 +87,14 @@ module Doorkeeper
end
else
respond_to do |format|
format.html { render :edit }
format.html do
render inertia: "OAuthApplications/Edit", props: form_props(
heading: I18n.t("doorkeeper.applications.edit.title"),
subheading: "Update the settings for #{@application.name}.",
submit_path: oauth_application_path(@application),
form_method: "patch"
), status: :unprocessable_entity
end
format.json do
errors = @application.errors.full_messages
render json: { errors: errors }, status: :unprocessable_entity
@ -120,7 +152,153 @@ module Doorkeeper
end
def current_resource_owner
User.find_by(id: session[:user_id]) if session[:user_id]
current_user
end
def authenticate_oauth_owner!
return if current_resource_owner
redirect_to minimal_login_path(continue: request.fullpath)
end
def index_props
{
page_title: "OAuth Applications",
heading: I18n.t("doorkeeper.applications.index.title"),
subheading: "Manage your OAuth applications that integrate with Hackatime.",
new_application_path: new_oauth_application_path,
applications: @applications.map { |application|
{
id: application.id,
name: application.name,
verified: application.verified?,
confidential: application.confidential?,
scopes: application.scopes.to_a.map(&:to_s),
redirect_uris: redirect_uris_for(application),
show_path: oauth_application_path(application),
edit_path: edit_oauth_application_path(application),
destroy_path: oauth_application_path(application)
}
}
}
end
def show_props
secret = flash[:application_secret].presence || @application.plaintext_secret
{
page_title: "#{@application.name} - OAuth Application",
heading: I18n.t("doorkeeper.applications.show.title", name: @application.name),
subheading: "OAuth application credentials and settings.",
application: {
id: @application.id,
name: @application.name,
uid: @application.uid,
verified: @application.verified?,
confidential: @application.confidential?,
scopes: @application.scopes.to_a.map(&:to_s),
redirect_uris: redirect_uris_for(@application).map { |uri|
{
value: uri,
authorize_path: oauth_authorization_path(
client_id: @application.uid,
redirect_uri: uri,
response_type: "code",
scope: @application.scopes.to_s
)
}
},
edit_path: edit_oauth_application_path(@application),
destroy_path: oauth_application_path(@application),
rotate_secret_path: rotate_secret_oauth_application_path(@application),
index_path: oauth_applications_path,
toggle_verified_path: (
current_user&.admin_level_superadmin? ?
toggle_verified_admin_oauth_application_path(@application) :
nil
)
},
secret: {
value: secret,
hashed: secret.blank? && Doorkeeper.config.application_secret_hashed?,
just_rotated: flash[:application_secret].present?
},
labels: {
application_id: I18n.t("doorkeeper.applications.show.application_id"),
secret: I18n.t("doorkeeper.applications.show.secret"),
secret_hashed: I18n.t("doorkeeper.applications.show.secret_hashed"),
scopes: I18n.t("doorkeeper.applications.show.scopes"),
confidential: I18n.t("doorkeeper.applications.show.confidential"),
callback_urls: I18n.t("doorkeeper.applications.show.callback_urls"),
actions: I18n.t("doorkeeper.applications.show.actions"),
not_defined: I18n.t("doorkeeper.applications.show.not_defined")
},
confirmations: {
rotate_secret: "Are you sure? This will invalidate your current secrets and break existing integrations."
}
}
end
def form_props(heading:, subheading:, submit_path:, form_method:)
{
page_title: heading,
heading: heading,
subheading: subheading,
submit_path: submit_path,
form_method: form_method,
cancel_path: oauth_applications_path,
labels: {
submit: I18n.t("doorkeeper.applications.buttons.submit"),
cancel: I18n.t("doorkeeper.applications.buttons.cancel")
},
help_text: {
redirect_uri: I18n.t("doorkeeper.applications.help.redirect_uri"),
blank_redirect_uri: I18n.t("doorkeeper.applications.help.blank_redirect_uri"),
confidential: I18n.t("doorkeeper.applications.help.confidential")
},
allow_blank_redirect_uri: Doorkeeper.configuration.allow_blank_redirect_uri?(@application),
application: {
id: @application.id,
persisted: @application.persisted?,
name: @application.name.to_s,
redirect_uri: @application.redirect_uri.to_s,
confidential: @application.confidential?,
verified: @application.verified?,
selected_scopes: selected_scopes_for(@application)
},
scope_options: all_scope_options,
errors: {
full_messages: @application.errors.full_messages,
name: @application.errors[:name],
redirect_uri: @application.errors[:redirect_uri],
scopes: @application.errors[:scopes],
confidential: @application.errors[:confidential]
}
}
end
def selected_scopes_for(application)
scopes = application.scopes.to_a.map(&:to_s)
return scopes if scopes.any? || application.persisted?
Doorkeeper.configuration.default_scopes.to_a.map(&:to_s)
end
def all_scope_options
default_scopes = Doorkeeper.configuration.default_scopes.to_a.map(&:to_s)
optional_scopes = Doorkeeper.configuration.optional_scopes.to_a.map(&:to_s)
(default_scopes + optional_scopes).uniq.map { |scope|
{
value: scope,
description: I18n.t(scope, scope: %i[doorkeeper scopes], default: scope.humanize),
default: default_scopes.include?(scope)
}
}
end
def redirect_uris_for(application)
application.redirect_uri.to_s.split
end
end
end

View file

@ -41,7 +41,11 @@ class InertiaController < ApplicationController
end
def inertia_flash_messages
flash.to_hash.map do |type, message|
allowed_types = %w[notice success alert error]
flash.to_hash.filter_map do |type, message|
next unless allowed_types.include?(type.to_s)
{
message: message.to_s,
class_name: flash_class_for(type)
@ -68,7 +72,7 @@ class InertiaController < ApplicationController
links << inertia_link("Docs", docs_path, active: helpers.current_page?(docs_path) || request.path.start_with?("/docs"), inertia: true)
links << inertia_link("Extensions", extensions_path, active: helpers.current_page?(extensions_path), inertia: true)
links << inertia_link("Settings", my_settings_path, active: request.path.start_with?("/my/settings"), inertia: true)
links << inertia_link("My OAuth Apps", oauth_applications_path, active: helpers.current_page?(oauth_applications_path) || request.path.start_with?("/oauth/applications"))
links << inertia_link("My OAuth Apps", oauth_applications_path, active: helpers.current_page?(oauth_applications_path) || request.path.start_with?("/oauth/applications"), inertia: true)
links << { label: "Logout", action: "logout" }
else
links << inertia_link("Docs", docs_path, active: helpers.current_page?(docs_path) || request.path.start_with?("/docs"), inertia: true)

View file

@ -15,13 +15,13 @@
return dates;
}
function bgColor(seconds: number, busiestDaySeconds: number): string {
if (seconds < 60) return "bg-[#151b23]";
function intensityClass(seconds: number, busiestDaySeconds: number): string {
if (seconds < 60) return "activity-cell--0";
const ratio = seconds / busiestDaySeconds;
if (ratio >= 0.8) return "bg-[#56d364]";
if (ratio >= 0.5) return "bg-[#2ea043]";
if (ratio >= 0.2) return "bg-[#196c2e]";
return "bg-[#033a16]";
if (ratio >= 0.8) return "activity-cell--4";
if (ratio >= 0.5) return "activity-cell--3";
if (ratio >= 0.2) return "activity-cell--2";
return "activity-cell--1";
}
function durationInWords(seconds: number): string {
@ -40,7 +40,7 @@
{#each dates as date}
{@const seconds = data.duration_by_date[date] ?? 0}
<Link
class="day transition-all duration-75 w-3 h-3 rounded-sm hover:scale-110 hover:z-10 hover:shadow-md {bgColor(
class="day activity-cell transition-all duration-75 w-3 h-3 rounded-sm hover:scale-110 hover:z-10 hover:shadow-md {intensityClass(
seconds,
data.busiest_day_seconds,
)}"

View file

@ -0,0 +1,62 @@
<script lang="ts">
import Button from "../../components/Button.svelte";
import Modal from "../../components/Modal.svelte";
type HttpMethod = "post" | "delete" | "patch";
let {
open = $bindable(false),
title,
description,
actionPath,
confirmLabel,
csrfToken,
method = "post",
confirmStyle = "primary",
}: {
open?: boolean;
title: string;
description: string;
actionPath: string;
confirmLabel: string;
csrfToken: string;
method?: HttpMethod;
confirmStyle?: "primary" | "danger";
} = $props();
const close = () => {
open = false;
};
const isDelete = $derived(method === "delete");
</script>
<Modal bind:open {title} {description} maxWidth="max-w-md" hasActions>
{#snippet actions()}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
type="button"
variant="dark"
class="h-10 w-full border border-surface-300 text-muted"
onclick={close}
>
Cancel
</Button>
<form method="post" action={actionPath} class="m-0">
{#if method !== "post"}
<input type="hidden" name="_method" value={method} />
{/if}
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button
type="submit"
variant={confirmStyle === "danger" ? "surface" : "primary"}
class={`h-10 w-full ${confirmStyle === "danger" || isDelete ? "!border-red/45 !bg-red/15 !text-red hover:!bg-red/25" : "text-on-primary"}`}
>
{confirmLabel}
</Button>
</form>
</div>
{/snippet}
</Modal>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import Form from "./Form.svelte";
import type { OAuthApplicationFormProps } from "./types";
let props: OAuthApplicationFormProps = $props();
</script>
<svelte:head>
<title>{props.page_title}</title>
</svelte:head>
<div class="mx-auto max-w-4xl space-y-4">
<header>
<h1 class="text-3xl font-bold text-surface-content">{props.heading}</h1>
<p class="mt-1 text-sm text-muted">{props.subheading}</p>
</header>
<Form {...props} />
</div>

View file

@ -0,0 +1,196 @@
<script lang="ts">
import Button from "../../components/Button.svelte";
import type { OAuthApplicationFormProps } from "./types";
let {
submit_path,
form_method,
cancel_path,
labels,
help_text,
allow_blank_redirect_uri,
application,
scope_options,
errors,
}: OAuthApplicationFormProps = $props();
const csrfToken =
typeof document === "undefined"
? ""
: document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
let selectedScopes = $state([...(application.selected_scopes || [])]);
let confidential = $state(Boolean(application.confidential));
let redirectUri = $state(application.redirect_uri);
const nameLocked = $derived(application.persisted && application.verified);
</script>
{#if errors.full_messages.length > 0}
<div class="rounded-xl border border-red/40 bg-red/10 p-4">
<p class="text-sm font-semibold text-red">Fix the following errors:</p>
<ul class="mt-2 list-disc space-y-1 pl-5 text-sm text-red/85">
{#each errors.full_messages as error}
<li>{error}</li>
{/each}
</ul>
</div>
{/if}
<form method="post" action={submit_path} class="space-y-5">
{#if form_method === "patch"}
<input type="hidden" name="_method" value="patch" />
{/if}
<input type="hidden" name="authenticity_token" value={csrfToken} />
<section class="rounded-xl border border-surface-200 bg-dark p-6">
<h2 class="text-lg font-semibold text-surface-content">
Application details
</h2>
<div class="mt-5 space-y-5">
<div>
<label
for="doorkeeper_application_name"
class="mb-2 block text-sm font-medium text-surface-content"
>
Name
</label>
{#if nameLocked}
<input
id="doorkeeper_application_name"
type="text"
value={application.name}
class="w-full cursor-not-allowed rounded-md border border-surface-200 bg-darker/60 px-3 py-2 text-sm text-muted"
disabled
/>
<input
type="hidden"
name="doorkeeper_application[name]"
value={application.name}
/>
<p class="mt-2 text-xs text-yellow">
Name is locked for verified applications. Contact a superadmin to
change it.
</p>
{:else}
<input
id="doorkeeper_application_name"
name="doorkeeper_application[name]"
value={application.name}
required
placeholder="My Awesome App"
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
{/if}
{#if errors.name.length > 0}
<p class="mt-1 text-xs text-red">{errors.name[0]}</p>
{/if}
</div>
<div>
<label
for="doorkeeper_application_redirect_uri"
class="mb-2 block text-sm font-medium text-surface-content"
>
Redirect URIs
</label>
<textarea
id="doorkeeper_application_redirect_uri"
name="doorkeeper_application[redirect_uri]"
rows="4"
bind:value={redirectUri}
placeholder="https://example.com/auth/callback"
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 font-mono text-sm text-surface-content focus:border-primary focus:outline-none"
></textarea>
<p class="mt-2 text-xs text-muted">{help_text.redirect_uri}</p>
{#if allow_blank_redirect_uri}
<p class="mt-1 text-xs text-muted">{help_text.blank_redirect_uri}</p>
{/if}
{#if errors.redirect_uri.length > 0}
<p class="mt-1 text-xs text-red">{errors.redirect_uri[0]}</p>
{/if}
</div>
<div>
<p class="mb-2 block text-sm font-medium text-surface-content">
Scopes
</p>
<input
type="hidden"
name="doorkeeper_application[scopes]"
value={selectedScopes.join(" ")}
/>
<div class="space-y-2">
{#each scope_options as scope}
<label
class="flex cursor-pointer items-start gap-3 rounded-lg border border-surface-200 bg-darker/70 p-3 hover:border-surface-300"
for={`scope_${scope.value}`}
>
<input
id={`scope_${scope.value}`}
type="checkbox"
value={scope.value}
bind:group={selectedScopes}
class="mt-1 h-4 w-4 rounded border-surface-300 bg-darker text-primary"
/>
<span>
<span class="text-sm font-medium text-surface-content">
{scope.value}
{#if scope.default}
<span class="ml-1 text-xs text-primary">(default)</span>
{/if}
</span>
<span class="mt-1 block text-xs text-muted"
>{scope.description}</span
>
</span>
</label>
{/each}
</div>
{#if errors.scopes.length > 0}
<p class="mt-1 text-xs text-red">{errors.scopes[0]}</p>
{/if}
</div>
<label
class="flex cursor-pointer items-start gap-3 rounded-lg border border-surface-200 bg-darker/70 p-3 hover:border-surface-300"
for="doorkeeper_application_confidential"
>
<input
type="hidden"
name="doorkeeper_application[confidential]"
value="0"
/>
<input
id="doorkeeper_application_confidential"
type="checkbox"
name="doorkeeper_application[confidential]"
value="1"
bind:checked={confidential}
class="mt-1 h-4 w-4 rounded border-surface-300 bg-darker text-primary"
/>
<span>
<span class="text-sm font-medium text-surface-content"
>Confidential application</span
>
<span class="mt-1 block text-xs text-muted"
>{help_text.confidential}</span
>
</span>
</label>
</div>
</section>
<div class="flex flex-wrap gap-3">
<Button type="submit" variant="primary">{labels.submit}</Button>
<Button href={cancel_path} variant="surface">{labels.cancel}</Button>
</div>
</form>

View file

@ -0,0 +1,176 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import Button from "../../components/Button.svelte";
import DestructiveActionModal from "./DestructiveActionModal.svelte";
import type { OAuthApplicationsIndexProps } from "./types";
let {
page_title,
heading,
subheading,
new_application_path,
applications,
}: OAuthApplicationsIndexProps = $props();
const csrfToken =
typeof document === "undefined"
? ""
: document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
let deleteModalOpen = $state(false);
let pendingDelete = $state<{ name: string; path: string } | null>(null);
const openDeleteModal = (applicationName: string, destroyPath: string) => {
pendingDelete = { name: applicationName, path: destroyPath };
deleteModalOpen = true;
};
</script>
<svelte:head>
<title>{page_title}</title>
</svelte:head>
<div class="mx-auto max-w-6xl space-y-6">
<header class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="text-3xl font-bold text-surface-content">{heading}</h1>
<p class="mt-1 text-sm text-muted">{subheading}</p>
</div>
<Button href={new_application_path} variant="primary"
>New application</Button
>
</header>
{#if applications.length > 0}
<div class="space-y-3">
{#each applications as application (application.id)}
<article class="rounded-xl border border-surface-200 bg-dark p-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0 flex-1 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<h2 class="truncate text-lg font-semibold text-surface-content">
{application.name}
</h2>
{#if application.verified}
<span
class="rounded-full border border-green/40 bg-green/15 px-2 py-0.5 text-xs font-semibold text-green"
>
Verified
</span>
{/if}
{#if application.confidential}
<span
class="rounded-full border border-primary/35 bg-primary/12 px-2 py-0.5 text-xs font-semibold text-primary"
>
Confidential
</span>
{/if}
</div>
<div class="space-y-2">
<div>
<p class="text-xs uppercase tracking-wide text-muted">
Callback URLs
</p>
{#if application.redirect_uris.length > 0}
<div class="mt-1 flex flex-wrap gap-1.5">
{#each application.redirect_uris as uri}
<span
class="max-w-full truncate rounded-md border border-surface-200 bg-darker px-2 py-1 font-mono text-xs text-surface-content"
>
{uri}
</span>
{/each}
</div>
{:else}
<p class="mt-1 text-sm text-muted">
No callback URLs configured.
</p>
{/if}
</div>
<div>
<p class="text-xs uppercase tracking-wide text-muted">
Scopes
</p>
{#if application.scopes.length > 0}
<div class="mt-1 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="mt-1 text-sm text-muted">No scopes configured.</p>
{/if}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<Link
href={application.show_path}
class="inline-flex items-center justify-center rounded-lg border border-surface-200 bg-surface-100 px-3 py-2 text-sm font-medium text-surface-content transition-colors hover:bg-surface-200"
>
View
</Link>
<Link
href={application.edit_path}
class="inline-flex items-center justify-center rounded-lg border border-primary bg-primary px-3 py-2 text-sm font-medium text-on-primary transition-opacity hover:opacity-90"
>
Edit
</Link>
<Button
type="button"
variant="surface"
class="!border-red/45 !bg-red/15 !text-red hover:!bg-red/25"
onclick={() =>
openDeleteModal(application.name, application.destroy_path)}
>
Delete
</Button>
</div>
</div>
</article>
{/each}
</div>
{:else}
<section
class="rounded-xl border border-surface-200 bg-dark p-10 text-center"
>
<h2 class="text-xl font-semibold text-surface-content">
No applications yet
</h2>
<p class="mt-2 text-sm text-muted">
Create your first OAuth application to start integrating with Hackatime.
</p>
<div class="mt-5">
<Button href={new_application_path} variant="primary"
>New application</Button
>
</div>
</section>
{/if}
</div>
<DestructiveActionModal
bind:open={deleteModalOpen}
title={pendingDelete
? `Delete ${pendingDelete.name}?`
: "Delete OAuth application?"}
description="This action permanently deletes the OAuth application and any integrations using it will stop working."
actionPath={pendingDelete?.path || ""}
confirmLabel="Delete application"
{csrfToken}
method="delete"
confirmStyle="danger"
/>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import Form from "./Form.svelte";
import type { OAuthApplicationFormProps } from "./types";
let props: OAuthApplicationFormProps = $props();
</script>
<svelte:head>
<title>{props.page_title}</title>
</svelte:head>
<div class="mx-auto max-w-4xl space-y-4">
<header>
<h1 class="text-3xl font-bold text-surface-content">{props.heading}</h1>
<p class="mt-1 text-sm text-muted">{props.subheading}</p>
</header>
<Form {...props} />
</div>

View file

@ -0,0 +1,329 @@
<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 copyValue = async (key: "uid" | "secret") => {
const value = key === "uid" ? application.uid : secret.value || "";
if (!value) return;
try {
await navigator.clipboard.writeText(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>
{#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/15 !text-yellow hover:!bg-yellow/25" : "!border-green/45 !bg-green/15 !text-green hover:!bg-green/25"}`}
>
{application.verified
? "Remove verification"
: "Verify application"}
</Button>
</form>
{/if}
<Button
type="button"
variant="outlinePrimary"
class="w-full"
onclick={() => openDestructiveModal("rotate")}
>
Rotate secret
</Button>
<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}
/>

View file

@ -0,0 +1,110 @@
export type OAuthApplicationSummary = {
id: number;
name: string;
verified: boolean;
confidential: boolean;
scopes: string[];
redirect_uris: string[];
show_path: string;
edit_path: string;
destroy_path: string;
};
export type OAuthApplicationsIndexProps = {
page_title: string;
heading: string;
subheading: string;
new_application_path: string;
applications: OAuthApplicationSummary[];
};
export type OAuthScopeOption = {
value: string;
description: string;
default: boolean;
};
export type OAuthApplicationFormApplication = {
id: number | null;
persisted: boolean;
name: string;
redirect_uri: string;
confidential: boolean;
verified: boolean;
selected_scopes: string[];
};
export type OAuthApplicationFormErrors = {
full_messages: string[];
name: string[];
redirect_uri: string[];
scopes: string[];
confidential: string[];
};
export type OAuthApplicationFormProps = {
page_title: string;
heading: string;
subheading: string;
submit_path: string;
form_method: "post" | "patch";
cancel_path: string;
labels: {
submit: string;
cancel: string;
};
help_text: {
redirect_uri: string;
blank_redirect_uri: string;
confidential: string;
};
allow_blank_redirect_uri: boolean;
application: OAuthApplicationFormApplication;
scope_options: OAuthScopeOption[];
errors: OAuthApplicationFormErrors;
};
export type OAuthShowRedirectUri = {
value: string;
authorize_path: string;
};
export type OAuthApplicationShowApplication = {
id: number;
name: string;
uid: string;
verified: boolean;
confidential: boolean;
scopes: string[];
redirect_uris: OAuthShowRedirectUri[];
edit_path: string;
destroy_path: string;
rotate_secret_path: string;
index_path: string;
toggle_verified_path: string | null;
};
export type OAuthApplicationShowProps = {
page_title: string;
heading: string;
subheading: string;
application: OAuthApplicationShowApplication;
secret: {
value: string | null;
hashed: boolean;
just_rotated: boolean;
};
labels: {
application_id: string;
secret: string;
secret_hashed: string;
scopes: string;
confidential: string;
callback_urls: string;
actions: string;
not_defined: string;
};
confirmations: {
rotate_secret: string;
};
};

View file

@ -191,7 +191,7 @@
{#if !github_connected}
<div class="mb-4 rounded-xl border border-yellow/30 bg-yellow/10 p-4">
<p class="text-base font-medium text-yellow">
<p class="text-base font-medium text-surface-content">
Heads up! You can't link projects to GitHub until you connect your
account.
</p>
@ -245,7 +245,7 @@
>
{#each projects_data.projects as project (project.id)}
<article
class="flex h-full flex-col gap-4 rounded-xl border border-primary bg-dark p-6 shadow-lg backdrop-blur-sm transition-all duration-300"
class="flex h-full flex-col gap-4 rounded-xl border border-primary bg-dark p-6 shadow-xs backdrop-blur-sm transition-all duration-300"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">

View file

@ -142,6 +142,40 @@ class User < ApplicationRecord
success: "#a3be8c",
warning: "#ebcb8b"
}
},
{
value: "rose",
label: "Rose Pine",
description: "Rose Pine inspired dark palette.",
color_scheme: "dark",
theme_color: "#eb6f92",
preview: {
darker: "#191724",
dark: "#1f1d2e",
darkless: "#26233a",
primary: "#eb6f92",
content: "#e0def4",
info: "#9ccfd8",
success: "#31748f",
warning: "#f6c177"
}
},
{
value: "rose_pine_dawn",
label: "Rose Pine Dawn",
description: "Rose Pine inspired light palette.",
color_scheme: "light",
theme_color: "#aa586f",
preview: {
darker: "#dfdad9",
dark: "#f2e9e1",
darkless: "#cecacd",
primary: "#aa586f",
content: "#575279",
info: "#56949f",
success: "#286983",
warning: "#a35a00"
}
}
].freeze
THEME_OPTION_BY_VALUE = THEME_OPTIONS.index_by { |theme| theme[:value] }.freeze
@ -196,7 +230,9 @@ class User < ApplicationRecord
gruvbox_dark: 4,
github_dark: 5,
github_light: 6,
nord: 7
nord: 7,
rose: 8,
rose_pine_dawn: 9
}
def can_convict_users?

View file

@ -3,7 +3,7 @@
<div class="w-full overflow-x-auto pb-2.5">
<div class="grid grid-rows-7 grid-flow-col gap-1 w-full lg:w-1/2">
<% 364.times do %>
<div class="w-3 h-3 bg-[#151b23] animate-pulse rounded-sm"></div>
<div class="w-3 h-3 activity-cell activity-cell--0 animate-pulse rounded-sm"></div>
<% end %>
</div>
<p class="super invisible">Calculated in UTC</p>

View file

@ -5,24 +5,23 @@
<% (365.days.ago.to_date..Time.current.to_date).to_a.each do |date| %>
<% duration = daily_durations[date] || 0 %>
<% if duration < 1.minute %>
<% level = 0 %>
<% bg_class = 'bg-[#151b23]' %>
<% bg_class = "activity-cell--0" %>
<% else %>
<% ratio = duration.to_f / length_of_busiest_day %>
<%
bg_class =
if ratio >= 0.8
'bg-[#56d364]'
"activity-cell--4"
elsif ratio >= 0.5
'bg-[#2ea043]'
"activity-cell--3"
elsif ratio >= 0.2
'bg-[#196c2e]'
"activity-cell--2"
else
'bg-[#033a16]'
"activity-cell--1"
end
%>
<% end %>
<a class="day transition-all duration-75 w-3 h-3 rounded-sm hover:scale-110 hover:z-10 hover:shadow-md <%= bg_class %>" href="?date=<%= date %>" data-turbo-frame="_top" data-date="<%= date %>" data-duration="<%= distance_of_time_in_words(duration) %>" title="you hacked for <%= distance_of_time_in_words(duration) %> on <%= date %>"> </a>
<a class="day activity-cell transition-all duration-75 w-3 h-3 rounded-sm hover:scale-110 hover:z-10 hover:shadow-md <%= bg_class %>" href="?date=<%= date %>" data-turbo-frame="_top" data-date="<%= date %>" data-duration="<%= distance_of_time_in_words(duration) %>" title="you hacked for <%= distance_of_time_in_words(duration) %> on <%= date %>"> </a>
<% end %>
</div>
<p class="super">

View file

@ -10,8 +10,6 @@ Rails.configuration.to_prepare do
end
end
end
Doorkeeper::ApplicationsController.layout "application" # show oauth2 admin in normal hackatime ui
end
class String
# Hopefully this is the right place! It a really good monkey patch!!

View file

@ -0,0 +1,201 @@
require "test_helper"
require "json"
require "nokogiri"
class Doorkeeper::ApplicationsControllerTest < ActionDispatch::IntegrationTest
test "index redirects guests to minimal login" do
get oauth_applications_path
assert_response :redirect
assert_redirected_to minimal_login_path(continue: oauth_applications_path)
end
test "index renders only current user's applications in inertia payload" do
user = User.create!(timezone: "UTC")
other_user = User.create!(timezone: "UTC")
user_application = create_application_for(user, name: "Owner App")
create_application_for(other_user, name: "Other App")
sign_in_as(user)
get oauth_applications_path
assert_response :success
page = inertia_page
assert_equal "OAuthApplications/Index", page["component"]
assert_equal [ user_application.id ], page.dig("props", "applications").map { |application| application["id"] }
assert_equal [ "Owner App" ], page.dig("props", "applications").map { |application| application["name"] }
end
test "show returns 404 for applications owned by another user" do
user = User.create!(timezone: "UTC")
other_user = User.create!(timezone: "UTC")
other_user_application = create_application_for(other_user, name: "Private App")
sign_in_as(user)
get oauth_application_path(other_user_application)
assert_response :not_found
end
test "show renders inertia payload with application details" do
user = User.create!(timezone: "UTC")
application = create_application_for(user, name: "Show App")
sign_in_as(user)
get oauth_application_path(application)
assert_response :success
page = inertia_page
assert_equal "OAuthApplications/Show", page["component"]
assert_equal application.id, page.dig("props", "application", "id")
assert_equal application.name, page.dig("props", "application", "name")
assert_equal rotate_secret_oauth_application_path(application), page.dig("props", "application", "rotate_secret_path")
end
test "create persists owned application and redirects to show" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
assert_difference -> { OauthApplication.count }, 1 do
post oauth_applications_path, params: {
doorkeeper_application: valid_application_params(name: "Created App")
}
end
created_application = OauthApplication.order(:created_at).last
assert_equal user, created_application.owner
assert_redirected_to oauth_application_url(created_application)
assert flash[:application_secret].present?
end
test "create invalid re-renders inertia new with validation errors" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
post oauth_applications_path, params: {
doorkeeper_application: valid_application_params(name: "")
}
assert_response :unprocessable_entity
page = inertia_page
assert_equal "OAuthApplications/New", page["component"]
assert_not_empty page.dig("props", "errors", "full_messages")
end
test "create invalid json returns errors" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
post oauth_applications_path(format: :json), params: {
doorkeeper_application: valid_application_params(name: "")
}
assert_response :unprocessable_entity
body = JSON.parse(response.body)
assert_not_empty body["errors"]
end
test "update persists changes and redirects to show" do
user = User.create!(timezone: "UTC")
application = create_application_for(user, name: "Before")
sign_in_as(user)
patch oauth_application_path(application), params: {
doorkeeper_application: { name: "After" }
}
assert_redirected_to oauth_application_url(application)
assert_equal "After", application.reload.name
end
test "update invalid re-renders inertia edit with validation errors" do
user = User.create!(timezone: "UTC")
application = create_application_for(user, name: "Valid Name")
sign_in_as(user)
patch oauth_application_path(application), params: {
doorkeeper_application: { name: "" }
}
assert_response :unprocessable_entity
page = inertia_page
assert_equal "OAuthApplications/Edit", page["component"]
assert_not_empty page.dig("props", "errors", "name")
end
test "destroy removes application and redirects to index" do
user = User.create!(timezone: "UTC")
application = create_application_for(user, name: "Delete Me")
sign_in_as(user)
assert_difference -> { OauthApplication.count }, -1 do
delete oauth_application_path(application)
end
assert_redirected_to oauth_applications_url
end
test "rotate_secret updates secret and redirects to show" do
user = User.create!(timezone: "UTC")
application = create_application_for(user, name: "Rotate Me")
previous_secret = application.secret
sign_in_as(user)
post rotate_secret_oauth_application_path(application)
assert_redirected_to oauth_application_url(application)
assert_not_equal previous_secret, application.reload.secret
assert flash[:application_secret].present?
assert flash[:notice].present?
end
test "show json returns application data for owner" do
user = User.create!(timezone: "UTC")
application = create_application_for(user, name: "JSON App")
sign_in_as(user)
get oauth_application_path(application, format: :json)
assert_response :success
body = JSON.parse(response.body)
assert_equal application.id, body["id"]
assert_equal application.uid, body["uid"]
assert_equal application.name, body["name"]
end
private
def inertia_page
document = Nokogiri::HTML(response.body)
page_script = document.at_css("script[data-page='app'][type='application/json']")
assert_not_nil page_script, "Expected Inertia page payload script in response body"
JSON.parse(page_script.text)
end
def valid_application_params(name:)
{
name: name,
redirect_uri: "https://example.com/callback",
scopes: configured_scopes,
confidential: "1"
}
end
def create_application_for(user, name:)
user.oauth_applications.create!(valid_application_params(name: name))
end
def configured_scopes
Doorkeeper.configuration.default_scopes.to_a.join(" ")
end
def sign_in_as(user)
token = user.sign_in_tokens.create!(auth_type: :email)
get auth_token_path(token: token.token)
assert_equal user.id, session[:user_id]
end
end

View file

@ -19,6 +19,8 @@ class UserTest < ActiveSupport::TestCase
github_dark
github_light
nord
rose
rose_pine_dawn
], values
end