mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 21:05:15 +00:00
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:
parent
8adf2a7fce
commit
ef94a9da9d
19 changed files with 1460 additions and 31 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
19
app/javascript/pages/OAuthApplications/Edit.svelte
Normal file
19
app/javascript/pages/OAuthApplications/Edit.svelte
Normal 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>
|
||||
196
app/javascript/pages/OAuthApplications/Form.svelte
Normal file
196
app/javascript/pages/OAuthApplications/Form.svelte
Normal 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>
|
||||
176
app/javascript/pages/OAuthApplications/Index.svelte
Normal file
176
app/javascript/pages/OAuthApplications/Index.svelte
Normal 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"
|
||||
/>
|
||||
19
app/javascript/pages/OAuthApplications/New.svelte
Normal file
19
app/javascript/pages/OAuthApplications/New.svelte
Normal 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>
|
||||
329
app/javascript/pages/OAuthApplications/Show.svelte
Normal file
329
app/javascript/pages/OAuthApplications/Show.svelte
Normal 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}
|
||||
/>
|
||||
110
app/javascript/pages/OAuthApplications/types.ts
Normal file
110
app/javascript/pages/OAuthApplications/types.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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!!
|
||||
|
|
|
|||
201
test/controllers/doorkeeper/applications_controller_test.rb
Normal file
201
test/controllers/doorkeeper/applications_controller_test.rb
Normal 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
|
||||
|
|
@ -19,6 +19,8 @@ class UserTest < ActiveSupport::TestCase
|
|||
github_dark
|
||||
github_light
|
||||
nord
|
||||
rose
|
||||
rose_pine_dawn
|
||||
], values
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue