Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
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 saved = localStorage.getItem('theme');
const theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme === 'dark' ? 'public' : 'admin'); // Default logic matching AppRoot
})();
</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