Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400&family=Noto+Serif+JP:wght@200;300&display=swap"
rel="stylesheet" />
<link rel="stylesheet" href="./src/index.css" />
<script>
// FOUC prevention: initialize theme before first paint
(function() {
const isAdmin = window.location.pathname === '/admin' || window.location.pathname.startsWith('/admin/');
document.documentElement.setAttribute('data-theme', isAdmin ? 'admin' : 'public');
})();
</script>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<script type="module" src="/src/main.ts"></script>
</head>

Expand Down
76 changes: 76 additions & 0 deletions frontend/src/components/admin/ui/me-auth-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { consume } from '@lit/context'
import { css, html, LitElement, nothing } from 'lit'
import { customElement } from 'lit/decorators.js'
import { authContext } from '../../../contexts/auth-context.js'
import { RepositoryObserver } from '../../../controllers/RepositoryObserver.js'
import type { IAuthRepository } from '../../../domain/AuthRepository.js'

/**
* A declarative component that protects its content based on authentication status.
*/
@customElement('me-auth-guard')
export class MeAuthGuard extends LitElement {
@consume({ context: authContext, subscribe: true })
set authRepo(repo: IAuthRepository) {
if (this._authRepo === repo) return
this._authRepo = repo
this._observer?.disconnect()
if (repo) this._observer = new RepositoryObserver(this, repo)
}
Comment on lines +13 to +19
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

authRepo の差し替え時に checkSession() が再実行されません。

この setter は observer を張り替えるだけなので、新しい repo が 'unknown' のまま入ってきたケースを取りこぼします。差し替え直後にも void this.checkSession() を呼んでおかないと、ガードが空描画のまま止まる可能性があります。

修正案
   set authRepo(repo: IAuthRepository) {
     if (this._authRepo === repo) return
     this._authRepo = repo
     this._observer?.disconnect()
-    if (repo) this._observer = new RepositoryObserver(this, repo)
+    if (repo) {
+      this._observer = new RepositoryObserver(this, repo)
+      void this.checkSession()
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@consume({ context: authContext, subscribe: true })
set authRepo(repo: IAuthRepository) {
if (this._authRepo === repo) return
this._authRepo = repo
this._observer?.disconnect()
if (repo) this._observer = new RepositoryObserver(this, repo)
}
`@consume`({ context: authContext, subscribe: true })
set authRepo(repo: IAuthRepository) {
if (this._authRepo === repo) return
this._authRepo = repo
this._observer?.disconnect()
if (repo) {
this._observer = new RepositoryObserver(this, repo)
void this.checkSession()
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/admin/ui/me-auth-guard.ts` around lines 13 - 19, The
authRepo setter only swaps the RepositoryObserver and doesn't re-run session
validation, so when a new IAuthRepository with state 'unknown' is injected the
guard can remain stuck; update the authRepo setter (the set authRepo(repo:
IAuthRepository) method) so after assigning this._authRepo and
creating/replacing this._observer (RepositoryObserver) you also invoke void
this.checkSession() (or await if appropriate) to trigger immediate session
re-check; ensure you still disconnect the old observer
(this._observer?.disconnect()) before creating the new one.

get authRepo() {
return this._authRepo
}
private _authRepo!: IAuthRepository
private _observer?: RepositoryObserver

connectedCallback() {
super.connectedCallback()
this.checkSession()
}

private async checkSession() {
if (this.authRepo?.status === 'unknown') {
await this.authRepo.refreshSession()
}
}

render() {
const status = this.authRepo?.status

if (status === 'checking' || status === 'unknown') {
return html`
<div class="guard-status">
<p>認証状態を確認しています...</p>
</div>
`
}

if (status === 'authenticated') {
return html`<slot></slot>`
}

// Guest or failed - render nothing, parent orchestrator will handle redirect
return nothing
}

static styles = css`
:host {
display: contents;
}

.guard-status {
min-height: 60dvh;
display: grid;
place-items: center;
color: var(--color-text-secondary);
font-size: 15px;
letter-spacing: var(--tracking-wide);
}
`
}

declare global {
interface HTMLElementTagNameMap {
'me-auth-guard': MeAuthGuard
}
}
44 changes: 31 additions & 13 deletions frontend/src/components/admin/ui/me-select.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { css, html, LitElement } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { classMap } from 'lit/directives/class-map.js'

@customElement('me-select')
export class MeSelect extends LitElement {
static formAssociated = true

@property() label = ''
@property() name = ''
@property() value = ''
@property({ type: Boolean }) disabled = false
@property({ type: Boolean }) required = false
@property({ reflect: true }) label = ''
@property({ reflect: true }) name = ''
@property({ reflect: true }) value = ''
@property({ type: Boolean, reflect: true }) disabled = false
@property({ type: Boolean, reflect: true }) required = false

private _internals: ElementInternals
private _inputId = `me-input-${Math.random().toString(36).slice(2, 9)}`
Expand All @@ -20,9 +19,13 @@ export class MeSelect extends LitElement {
this._internals = this.attachInternals()
}

protected createRenderRoot() {
return this.attachShadow({ mode: 'open', delegatesFocus: true })
}

formResetCallback() {
this.value = ''
this._internals.setFormValue('')
this._syncInternals()
}
Comment on lines 26 to 29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

formResetCallback() が空選択へ固定されています。

Line 27 で常に '' を入れると、初期選択があるフォームでも reset 後に元の option へ戻りません。ここも既定値へ戻す実装にしてください。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/admin/ui/me-select.ts` around lines 26 - 29, The
formResetCallback currently forces an empty selection by setting this.value = ''
which prevents restoring the original selected option; capture and persist the
initial/default value when the component is initialized (e.g., in the
constructor or connectedCallback into a field like this._initialValue) and then
change formResetCallback to restore that stored value (e.g., this.value =
this._initialValue) before calling this._syncInternals(); keep the existing
symbols formResetCallback, this.value, and this._syncInternals to locate and
update the code.


formDisabledCallback(disabled: boolean) {
Expand All @@ -32,7 +35,7 @@ export class MeSelect extends LitElement {
private _onChange(e: Event) {
const select = e.target as HTMLSelectElement
this.value = select.value
this._internals.setFormValue(select.value)
this._syncInternals()

this.dispatchEvent(
new CustomEvent('change', {
Expand All @@ -43,23 +46,38 @@ export class MeSelect extends LitElement {
)
}

updated(changedProperties: Map<PropertyKey, unknown>) {
protected updated(changedProperties: Map<PropertyKey, unknown>) {
if (changedProperties.has('value')) {
this._internals.setFormValue(this.value ?? '')
this._syncInternals()
}
}

private _syncInternals() {
this._internals.setFormValue(this.value ?? '')
const select = this.shadowRoot?.querySelector('select')
if (select) {
this._internals.setValidity(
select.validity,
select.validationMessage,
select,
)
this._internals.ariaInvalid = select.checkValidity() ? 'false' : 'true'
this._internals.ariaRequired = this.required ? 'true' : 'false'
}
}

render() {
return html`
<div class=${classMap({ field: true, disabled: this.disabled })}>
<div class="field">
${
this.label
? html`<label class="label" for=${this._inputId}>${this.label}</label>`
? html`<label class="label" for=${this._inputId} part="label">${this.label}</label>`
: null
}
<div class="select-wrapper">
<select
id=${this._inputId}
part="select"
.name=${this.name}
.value=${this.value ?? ''}
?disabled=${this.disabled}
Expand Down Expand Up @@ -130,7 +148,7 @@ export class MeSelect extends LitElement {
box-shadow: 0 0 0 1px var(--admin-accent);
}

select:disabled {
:host([disabled]) select {
background: var(--color-bg-deep);
color: var(--color-text-tertiary);
cursor: not-allowed;
Expand Down
29 changes: 9 additions & 20 deletions frontend/src/components/admin/ui/me-text-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { customElement, property } from 'lit/decorators.js'
/**
* A standard-compliant, form-associated text input component.
* Fully leverages ElementInternals and delegatesFocus for a native feel.
* Targets WCAG 2.1 AA compliance.
*/
@customElement('me-text-input')
export class MeTextInput extends LitElement {
Expand Down Expand Up @@ -34,11 +35,6 @@ export class MeTextInput extends LitElement {
this._internals = this.attachInternals()
}

/**
* Overrides createRenderRoot to enable focus delegation.
* This means focusing the <me-text-input> element will automatically
* focus the inner <input> tag.
*/
protected createRenderRoot() {
return this.attachShadow({ mode: 'open', delegatesFocus: true })
}
Expand All @@ -47,29 +43,18 @@ export class MeTextInput extends LitElement {

formResetCallback() {
this.value = ''
this._internals.setFormValue('')
this._syncInternals()
Comment on lines 44 to +46
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

formResetCallback() が初期値ではなく空文字へ固定されています。

Line 45 で常に '' を代入しているため、初期値付きのフォームでも form.reset() が元の値へ戻りません。ここは空文字固定ではなく、コンポーネントの既定値へ戻す必要があります。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/admin/ui/me-text-input.ts` around lines 44 - 46,
formResetCallback currently forces this.value to '' so form.reset() cannot
restore the component's initial/default value; change it to restore a stored
initial value (e.g., set this.value = this._initialValue) and keep the existing
this._syncInternals() call. Ensure you capture and store that initial value when
the element is created/connected (in the constructor or connectedCallback) by
reading the initial this.value or the initial attribute (e.g.,
this.getAttribute('value') or a defaultValue prop) into this._initialValue so
formResetCallback and other logic can restore the original default.

}

formDisabledCallback(disabled: boolean) {
this.disabled = disabled
}

// --- Validation ---

checkValidity() {
return this._internals.checkValidity()
}

reportValidity() {
return this._internals.reportValidity()
}

private _onInput(e: Event) {
const input = e.target as HTMLInputElement
this.value = input.value
this._syncInternals()

// Bubbles the event as a standard 'change' event for parent listeners
this.dispatchEvent(
new CustomEvent('change', {
detail: input.value,
Expand All @@ -89,20 +74,25 @@ export class MeTextInput extends LitElement {
const val = String(this.value ?? '')
this._internals.setFormValue(val)

// Simple native validation sync
const input = this.shadowRoot?.querySelector('input')
if (input) {
// Synchronize native validation to the component's internal state
const isValid = input.checkValidity()
this._internals.setValidity(
input.validity,
input.validationMessage,
input,
)

// Update ARIA attributes via internals
this._internals.ariaInvalid = isValid ? 'false' : 'true'
this._internals.ariaRequired = this.required ? 'true' : 'false'
}
}

render() {
return html`
<div class="field" ?disabled=${this.disabled}>
<div class="field">
${
this.label
? html`<label class="label" for=${this._inputId} part="label">${this.label}</label>`
Expand Down Expand Up @@ -161,7 +151,6 @@ export class MeTextInput extends LitElement {
box-shadow: 0 0 0 1px var(--admin-accent);
}

/* Standard states via CSS classes or attributes */
:host([disabled]) input {
background: var(--color-bg-deep);
color: var(--color-text-tertiary);
Expand Down
14 changes: 4 additions & 10 deletions frontend/src/components/admin/ui/me-textarea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,13 @@ export class MeTextarea extends LitElement {

formResetCallback() {
this.value = ''
this._internals.setFormValue('')
this._syncInternals()
Comment on lines 31 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

textarea も reset 時に初期値へ戻っていません。

Line 32 が空文字固定なので、既定値付きフォームで form.reset() しても初期内容を復元できません。me-text-input/me-select と同様に、ここも保存している既定値へ戻す必要があります。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/admin/ui/me-textarea.ts` around lines 31 - 33, The
formResetCallback currently forces this.value = '' which breaks restoring
initial values on form.reset(); change formResetCallback (in me-textarea) to
restore the saved initial/default value (the same mechanism used by
me-text-input and me-select) instead of hardcoding empty string — e.g. set
this.value = this._initialValue (or this._defaultValue / the stored default you
use elsewhere) and then call this._syncInternals(); ensure the initial value is
captured at construction/connectedCallback so reset can revert to it.

}

formDisabledCallback(disabled: boolean) {
this.disabled = disabled
}

checkValidity() {
return this._internals.checkValidity()
}

reportValidity() {
return this._internals.reportValidity()
}

private _onInput(e: Event) {
const input = e.target as HTMLTextAreaElement
this.value = input.value
Expand Down Expand Up @@ -74,12 +66,14 @@ export class MeTextarea extends LitElement {
input.validationMessage,
input,
)
this._internals.ariaInvalid = input.checkValidity() ? 'false' : 'true'
this._internals.ariaRequired = this.required ? 'true' : 'false'
}
}

render() {
return html`
<div class="field" ?disabled=${this.disabled}>
<div class="field">
${
this.label
? html`<label class="label" for=${this._inputId} part="label">${this.label}</label>`
Expand Down
27 changes: 6 additions & 21 deletions frontend/src/components/app-admin-shell.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,25 @@
import { consume } from '@lit/context'
import { css, html, LitElement } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { classMap } from 'lit/directives/class-map.js'
import { authContext } from '../contexts/auth-context.js'
import { RepositoryObserver } from '../controllers/RepositoryObserver.js'
import type { IAuthRepository } from '../domain/AuthRepository.js'
import type { RouteShellElement } from './route-shell.js'
import { playLeaveTransition, routeShellStyles } from './route-shell.js'

@customElement('app-admin-shell')
export class AppAdminShell extends LitElement implements RouteShellElement {
@consume({ context: authContext, subscribe: true })
set authRepo(repo: IAuthRepository | undefined) {
if (this._authRepo === repo) return
this._authRepo = repo
if (this._observer) this._observer.disconnect()
if (repo) this._observer = new RepositoryObserver(this, repo)
}
get authRepo() {
return this._authRepo
}
private _authRepo?: IAuthRepository
private _observer?: RepositoryObserver
@property({ type: Boolean })
authenticated = false

@property()
currentPath = '/admin'

@property({ type: Boolean })
busy = false
isChecking = false

render() {
const authenticated = this.authRepo?.status === 'authenticated'
return html`
<div class=${classMap({ layout: true, 'with-sidebar': authenticated })}>
<div class=${classMap({ layout: true, 'with-sidebar': this.authenticated })}>
${
authenticated
this.authenticated
? html`
<aside class="sidebar">
<a href="/admin" class=${this.navClass('/admin')}>Dashboard</a>
Expand All @@ -53,7 +38,7 @@ export class AppAdminShell extends LitElement implements RouteShellElement {
}
<main id="outlet">
${
this.busy
this.isChecking
? html`<p class="status">セッションを確認しています...</p>`
: null
}
Expand Down
Loading