Skip to content
Draft
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
226 changes: 226 additions & 0 deletions .github/workflows/a11y-scan.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
name: Accessibility Scanner

on:
workflow_dispatch:
# TODO: remove push trigger before merging — workflow_dispatch only works from the default branch
Comment thread
onchul marked this conversation as resolved.
push:
branches:
- a11y-scanner-setup
Comment thread
onchul marked this conversation as resolved.

jobs:
build:
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

This workflow calls the reusable build-container-images.yaml, which requests packages: write for the job. Since reusable workflow permissions are capped by the caller, add workflow-level (or jobs.build) permissions (e.g., packages: write) to avoid the build job running with default token permissions and failing to authenticate/cache/upload as expected.

Suggested change
build:
build:
permissions:
contents: read
packages: write

Copilot uses AI. Check for mistakes.
uses: ./.github/workflows/build-container-images.yaml

accessibility_scanner:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {}
Comment on lines +12 to +14
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
env:
AUTH_INITIAL_ADMIN_USERNAME: admin@example.com
AUTH_INITIAL_ADMIN_PASSWORD: secret
steps:
Comment thread
onchul marked this conversation as resolved.
- name: Create Docker network
run: docker network create a11y-network

- name: Start database
run: |
Comment thread
onchul marked this conversation as resolved.
docker run -d \
--network a11y-network \
--name postgres \
-e POSTGRES_USER=admin \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=cccc \
pgvector/pgvector:pg16
Comment on lines +27 to +35
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

The Postgres container password is hardcoded (POSTGRES_PASSWORD=secret) and the same value is embedded later in DB_URL. Please wire the DB password through a secret or a generated runtime value (and reference it consistently) so credentials aren’t committed to the workflow and can be rotated safely.

Copilot uses AI. Check for mistakes.

- name: Wait for database
run: |
db_ready=false
for i in $(seq 1 30); do
if docker exec postgres pg_isready -U admin -d cccc; then
echo "Database is ready"
db_ready=true
break
fi
echo "Waiting for database... ($i/30)"
sleep 1
done

if [ "$db_ready" != "true" ]; then
echo "Database did not become ready in time"
exit 1
fi

- uses: actions/download-artifact@v8
with:
name: backend
path: /tmp/

- name: Start backend
run: |
docker load --input /tmp/backend.tar
docker run -d \
--network a11y-network \
--name backend \
-e BASE_URL="http://localhost:3080" \
-e DB_URL="postgres://admin:secret@postgres:5432/cccc" \
-e C4_DB_RETRY_DELAY="6000" \
-e NODE_ENV="development" \
-e AUTH_ENABLE_PASSWORD="true" \
-e AUTH_INITIAL_ADMIN_USERNAME="${{ env.AUTH_INITIAL_ADMIN_USERNAME }}" \
-e AUTH_INITIAL_ADMIN_PASSWORD="${{ env.AUTH_INITIAL_ADMIN_PASSWORD }}" \
-e SESSION_SECRET="a11y-scanner-session-secret" \
backend:commit-${{ github.sha }}

- uses: actions/download-artifact@v8
with:
name: frontend
path: /tmp/

- name: Start frontend
run: |
docker load --input /tmp/frontend.tar
docker run -d \
--network a11y-network \
--name frontend \
frontend:commit-${{ github.sha }}

- name: Start gateway proxy
run: |
docker run -d \
-p 3080:3080 \
--network a11y-network \
--name gateway \
-e FRONTEND_PORT=3080 \
-e PORT=3080 \
-e BACKEND_PORT=3000 \
ghcr.io/codecentric/c4-genai-suite/dev-helper/caddy-gateway-proxy:latest
Comment thread
onchul marked this conversation as resolved.

- name: Wait for application
run: |
app_ready=false
for i in $(seq 1 60); do
if curl -sf http://localhost:3080/api/health > /dev/null 2>&1; then
echo "Application is ready!"
app_ready=true
break
fi
echo "Waiting for application... ($i/60)"
sleep 2
done

if [ "$app_ready" != "true" ]; then
echo "Application did not become ready in time"
exit 1
fi

- name: Authenticate via API
id: auth
run: |
# Login via backend API and capture session cookie
curl -s -c /tmp/cookies.txt \
-X POST http://localhost:3080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"${{ env.AUTH_INITIAL_ADMIN_USERNAME }}","password":"${{ env.AUTH_INITIAL_ADMIN_PASSWORD }}"}'

# Convert Netscape cookie jar to Playwright auth_context JSON
# HttpOnly cookies are prefixed with #HttpOnly_ in curl's cookie jar
AUTH_CONTEXT=$(awk '
/^#HttpOnly_/ {
sub(/^#HttpOnly_/, "", $1)
printf "{\"name\":\"%s\",\"value\":\"%s\",\"domain\":\"%s\",\"path\":\"%s\",\"secure\":%s,\"httpOnly\":true}\n",
$6, $7, $1, $3, ($4=="TRUE"?"true":"false")
next
}
!/^#/ && NF {
printf "{\"name\":\"%s\",\"value\":\"%s\",\"domain\":\"%s\",\"path\":\"%s\",\"secure\":%s,\"httpOnly\":false}\n",
$6, $7, $1, $3, ($4=="TRUE"?"true":"false")
}
' /tmp/cookies.txt | jq -sc '{
username: "${{ env.AUTH_INITIAL_ADMIN_USERNAME }}",
password: "${{ env.AUTH_INITIAL_ADMIN_PASSWORD }}",
cookies: .,
localStorage: {}
}')
Comment on lines +127 to +145
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

The auth cookie extraction drops any lines starting with # (awk '!/^#/'). curl writes HttpOnly cookies in the Netscape cookie jar as lines prefixed with #HttpOnly_..., so this will likely discard the session cookie entirely and the scanner will run unauthenticated. Adjust the parsing to retain #HttpOnly_ cookie lines (stripping the prefix) and derive httpOnly correctly (the 2nd Netscape field is the include-subdomains flag, not HttpOnly).

Copilot uses AI. Check for mistakes.

echo "auth_context=$AUTH_CONTEXT" >> "$GITHUB_OUTPUT"
Comment on lines +140 to +147
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

auth_context is built to include username and password and then written to a step output. Even if the password is not a GitHub secret here, embedding credentials in outputs increases the chance of accidental exposure (logs/action debug output) and isn't necessary if cookie-based auth is sufficient. Consider omitting credentials from auth_context and only passing the minimal cookie/storage state required for Playwright.

Copilot uses AI. Check for mistakes.

- name: Verify axe timing issue (immediate vs after wait)
run: |
npm install playwright @axe-core/playwright
npx playwright install chromium --with-deps

node << 'SCRIPT'
const { chromium } = require('playwright');
const { default: AxeBuilder } = require('@axe-core/playwright');
const AUTH_CONTEXT = JSON.parse(process.env.AUTH_CONTEXT);

(async () => {
const browser = await chromium.launch();
const context = await browser.newContext({
storageState: {
cookies: AUTH_CONTEXT.cookies.map(c => ({ ...c, sameSite: 'Lax', expires: -1 })),
origins: []
}
});

const urls = [
'http://localhost:3080/admin/theme',
'http://localhost:3080/admin/dashboard',
'http://localhost:3080/chat'
];

for (const url of urls) {
const page = await context.newPage();
await page.goto(url);

const immediate = await new AxeBuilder({ page }).analyze();
console.log(`\n=== ${url} ===`);
console.log('Without wait:', immediate.violations.map(v => v.id));

await page.waitForLoadState('networkidle');
const waited = await new AxeBuilder({ page }).analyze();
console.log('With wait: ', waited.violations.map(v => v.id));

await page.close();
}

await browser.close();
})();
SCRIPT
env:
AUTH_CONTEXT: ${{ steps.auth.outputs.auth_context }}

Comment on lines +149 to +194
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

The "Verify axe timing issue" step installs Playwright/Axe at runtime via npm install without pinning versions or using actions/setup-node/npm ci with a lockfile, which can make the workflow slow and non-reproducible. If this is only for debugging, remove it before merging; otherwise, align it with the repo’s E2E workflow pattern (setup-node + cached npm ci + pinned deps).

Suggested change
- name: Verify axe timing issue (immediate vs after wait)
run: |
npm install playwright @axe-core/playwright
npx playwright install chromium --with-deps
node << 'SCRIPT'
const { chromium } = require('playwright');
const { default: AxeBuilder } = require('@axe-core/playwright');
const AUTH_CONTEXT = JSON.parse(process.env.AUTH_CONTEXT);
(async () => {
const browser = await chromium.launch();
const context = await browser.newContext({
storageState: {
cookies: AUTH_CONTEXT.cookies.map(c => ({ ...c, sameSite: 'Lax', expires: -1 })),
origins: []
}
});
const urls = [
'http://localhost:3080/admin/theme',
'http://localhost:3080/admin/dashboard',
'http://localhost:3080/chat'
];
for (const url of urls) {
const page = await context.newPage();
await page.goto(url);
const immediate = await new AxeBuilder({ page }).analyze();
console.log(`\n=== ${url} ===`);
console.log('Without wait:', immediate.violations.map(v => v.id));
await page.waitForLoadState('networkidle');
const waited = await new AxeBuilder({ page }).analyze();
console.log('With wait: ', waited.violations.map(v => v.id));
await page.close();
}
await browser.close();
})();
SCRIPT
env:
AUTH_CONTEXT: ${{ steps.auth.outputs.auth_context }}

Copilot uses AI. Check for mistakes.
- uses: github/accessibility-scanner@v3
with:
urls: |
http://localhost:3080
http://localhost:3080/chat
http://localhost:3080/admin/dashboard
http://localhost:3080/admin/theme
http://localhost:3080/admin/files
http://localhost:3080/admin/users
http://localhost:3080/admin/user-groups
http://localhost:3080/admin/audit-log
repository: ${{ github.repository }}
token: ${{ secrets.GH_TOKEN_A11Y }}
cache_key: cached_results-c4-local.json
Comment thread
onchul marked this conversation as resolved.
auth_context: ${{ steps.auth.outputs.auth_context }}
open_grouped_issues: true
skip_copilot_assignment: true
include_screenshots: true
# login_url: # Optional: URL of the login page if authentication is required
# username: # Optional: Username for authentication
# password: ${{ secrets.PASSWORD }} # Optional: Password for authentication (use secrets!)
# reduced_motion: no-preference # Optional: Playwright reduced motion configuration option
# color_scheme: light # Optional: Playwright color scheme configuration option
# scans: '["axe","reflow-scan"]' # Optional: An array of scans (or plugins) to be performed. If not provided, only Axe will be performed.

- name: Get backend logs
if: ${{ !cancelled() }}
run: docker logs backend

- name: Get gateway logs
if: ${{ !cancelled() }}
run: docker logs gateway
15 changes: 13 additions & 2 deletions frontend/src/pages/login/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,22 @@ function LoginForm() {
)}

<div className="mb-3">
<TextInput placeholder={texts.common.email} autoFocus key={form.key('email')} {...form.getInputProps('email')} />
<TextInput
placeholder={texts.common.email}
autoFocus
key={form.key('email')}
label="username"
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

The email field is labeled as "username", which doesn’t match the actual value being collected (email) and will be announced incorrectly by screen readers. Please use the same localized label used elsewhere (e.g., texts.common.email) or otherwise ensure the accessible name reflects "Email" rather than "username".

Suggested change
label="username"
label={texts.common.email}

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

The TextInput is bound to the email field (getInputProps('email') and placeholder uses texts.common.email), but the accessible label is set to the hard-coded string "username". This will be announced incorrectly by screen readers and is inconsistent with the rest of the UI that uses localized texts.* labels. Use an email-appropriate, localized label (and optionally visually-hide it if you don't want a visible label).

Suggested change
label="username"
label={texts.common.email}

Copilot uses AI. Check for mistakes.
{...form.getInputProps('email')}
/>
</div>

<div className="mb-3">
<PasswordInput placeholder={texts.common.password} key={form.key('password')} {...form.getInputProps('password')} />
<PasswordInput
placeholder={texts.common.password}
key={form.key('password')}
label="password"
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

The password field uses a hard-coded label string ("password") instead of the localized texts.common.password used across the app. This is user-visible and also affects accessibility naming/translation consistency; please switch to the shared texts label (or an equivalent localized string).

Suggested change
label="password"
label={texts.common.password}

Copilot uses AI. Check for mistakes.
{...form.getInputProps('password')}
/>
</div>

<Button type="submit" className="w-full">
Expand Down
Loading