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
19 changes: 19 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,25 @@ AUTH_ENABLE_SIGNUP=false
# Both halves are required before the GitHub button is offered.
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# Organizations are off unless this is exactly "true". Enabling them requires a working mail
# transport below, because invitations are delivered by email.
AUTH_ENABLE_ORGANIZATIONS=false
# Whether any signed-in user may create an organization. Ignored while organizations are off.
AUTH_ALLOW_ORGANIZATION_CREATION=false

# Mail (packages/mail). "console" logs instead of delivering and is the default, so an
# unconfigured deployment cannot silently attempt real delivery. Use "resend" or "smtp" in
# production.
MAIL_PROVIDER=console
MAIL_FROM=noreply@example.com
# Required when MAIL_PROVIDER=resend.
RESEND_API_KEY=
# Required when MAIL_PROVIDER=smtp. SMTP_SECURE is implicit TLS: true on 465, false on 587.
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASSWORD=

# Dashboard (apps/dashboard). Points the Better Auth client at the adapter. Sign-in capabilities
# are derived from AUTH_ENABLE_SIGNUP and the GitHub OAuth credentials above at build time, so
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/ci.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,3 +89,38 @@ jobs:
- name: 📦 Verify package version
run: |
node --input-type=module --eval "Promise.all([import('./packages/shared/dist/index.mjs'), import('./packages/shared/package.json', { with: { type: 'json' } })]).then(([built, pkg]) => { if (built.version !== pkg.default.version) { console.error('Injected version', built.version, 'does not match package.json version', pkg.default.version); process.exit(1) } })"

knip:
name: 🧹 Unused code check
runs-on: ubuntu-24.04-arm

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup
uses: ./.github/actions/setup-toolchain

- name: 🧹 Check for unused code
run: aube run knip

i18n:
name: 🌐 i18n validation
runs-on: ubuntu-24.04-arm

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Setup
uses: ./.github/actions/setup-toolchain

- name: 🌐 Check for missing or dynamic i18n keys
run: aube run i18n:report

- name: 🌐 Check i18n schema is up to date
run: |
aube run i18n:schema
git diff --exit-code packages/i18n/schemas packages/i18n/locales
3 changes: 2 additions & 1 deletion apps/auth-server/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,14 +6,15 @@
"scripts": {
"build": "tsdown",
"clean": "tsc -b --clean",
"dev": "node --import tsx --watch src/index.ts",
"dev": "node --env-file-if-exists=../../.env --import tsx --watch src/index.ts",
"lint": "oxlint --config ../../.oxlintrc.json --type-aware --type-check src",
"start": "node dist/index.js",
"test": "vitest run src --passWithNoTests",
"typecheck": "tsc --project tsconfig.json --pretty false --noEmit"
},
"dependencies": {
"@agent-zero/auth": "workspace:*",
"@agent-zero/mail": "workspace:*",
"@agent-zero/shared": "workspace:*",
"@hono/node-server": "^1.19.17",
"hono": "^4.13.1"
Expand Down
27 changes: 26 additions & 1 deletion apps/auth-server/src/index.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import process from 'node:process';

import { authOptionsFromEnvironment, createAuth } from '@agent-zero/auth';
import { createMailer, mailProviderNameFromEnvironment } from '@agent-zero/mail';
import { redactSecrets, secretValuesFromEnvironment } from '@agent-zero/shared';
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
Expand DownExpand Up@@ -37,7 +38,31 @@ function resolvePort(value: string | undefined): number {
}

const options = authOptionsFromEnvironment();
const auth = createAuth(options);

// This process is the composition root for authentication, so it is where the mail transport is
// bound and injected. `packages/auth` declares the delivery contract structurally and never
// imports `@agent-zero/mail`, which keeps one capability package from depending on another.
//
// The transport is only injected when the configured provider actually delivers: the console
// default logs an envelope instead of sending, so wiring it in would satisfy `createAuth`'s
// startup guard while every invitation silently reached nobody. Withholding the callback lets
// that guard fail startup when organizations are enabled without a real transport.
const sendMail = createMailer();
const deliversMail = mailProviderNameFromEnvironment() !== 'console';

const auth = createAuth({
...options,
...(deliversMail
? {
sendInvitationEmail: ({ to, organizationName, inviterName, acceptUrl }) =>
sendMail({
to,
templateId: 'organizationInvitation',
context: { organizationName, inviterName, acceptUrl },
}),
}
: {}),
});

const app = new Hono();

Expand Down
4 changes: 2 additions & 2 deletions apps/dashboard/app/app.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,9 +7,9 @@
</template>

<script setup lang="ts">
import { locales } from '@agent-zero/i18n';
import type { LocaleCode } from '@agent-zero/i18n';
import { app } from '~~/config/app.js';
import { locales } from '~~/config/i18n.js';
import type { LocaleCode } from '~~/config/i18n.js';

const { locale } = useI18n();

Expand Down
5 changes: 5 additions & 0 deletions apps/dashboard/app/auth.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
import { defineClientAuth } from '@onmax/nuxt-better-auth/config';
import { organizationClient } from 'better-auth/client/plugins';

// Better Auth runs in `apps/auth-server`, on its own origin, so in client-only mode `siteUrl`
// resolves to the auth adapter rather than to this app. Every call is therefore cross-origin: the
// adapter has to allow credentialed CORS and list the dashboard in its `trustedOrigins`.
export default defineClientAuth((ctx) => ({
baseURL: ctx.siteUrl,
fetchOptions: { credentials: 'include' },
// Registered unconditionally: the client plugin only adds callable methods, and whether the
// deployment actually serves them is decided by the auth server's own policy. Gating it on a
// build-time flag would let a stale dashboard build lose access to an enabled feature.
plugins: [organizationClient()],
}));
2 changes: 1 addition & 1 deletion apps/dashboard/app/error.vue
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,7 @@ import {
isNotFoundStatus,
isServerErrorStatus,
resolveErrorStatus,
} from '#shared/utils/error-status';
} from '~/modules/shared/utils/error-status';

const { error } = defineProps<{
error: NuxtError;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@
</template>

<script setup lang="ts">
import type { DashboardOverview } from '~/types/dashboard';
import type { DashboardOverview } from '~/modules/dashboard/types/dashboard';

const props = defineProps<{ overview: DashboardOverview }>();

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,7 +83,7 @@
</template>

<script setup lang="ts">
import type { DashboardTask } from '~/types/dashboard';
import type { DashboardTask } from '~/modules/dashboard/types/dashboard';

defineProps<{ task?: DashboardTask }>();

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@
</template>

<script setup lang="ts">
import type { DashboardTaskStatus } from '~/types/dashboard';
import type { DashboardTaskStatus } from '~/modules/dashboard/types/dashboard';

const props = defineProps<{ status: DashboardTaskStatus }>();

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@
</template>

<script setup lang="ts">
import type { DashboardTask } from '~/types/dashboard';
import type { DashboardTask } from '~/modules/dashboard/types/dashboard';

defineProps<{ tasks: DashboardTask[]; selectedId?: string }>();
defineEmits<{ select: [id: string] }>();
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@
</template>

<script setup lang="ts">
import type { DashboardTask } from '~/types/dashboard';
import type { DashboardTask } from '~/modules/dashboard/types/dashboard';

defineProps<{ task?: DashboardTask }>();
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
<template>
<section>
<h2 class="m-0 text-sm text-ink font-650">
{{ $t('organizations.invite.title') }}
</h2>

<form class="mt-3 flex flex-wrap items-end gap-2" @submit.prevent="onSubmit">
<div class="flex-1 min-w-48">
<label class="m-0 label-upper" :for="emailId">
{{ $t('organizations.invite.email') }}
</label>
<input
:id="emailId"
v-model="email"
class="focus-ring mt-1 h-8 w-full border border-line bg-raised px-2 text-xs text-ink"
type="email"
required
:disabled="pending"
/>
</div>

<div>
<label class="m-0 label-upper" :for="roleId">
{{ $t('organizations.invite.role') }}
</label>
<select
:id="roleId"
v-model="role"
class="focus-ring mt-1 h-8 border border-line bg-raised px-2 text-xs text-ink"
:disabled="pending"
>
<option v-for="option in roleOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>

<button
class="focus-ring h-8 border border-line bg-raised px-3 text-xs text-ink font-650 transition hover:border-muted"
type="submit"
:disabled="pending"
>
{{ pending ? $t('organizations.invite.submitPending') : $t('organizations.invite.submit') }}
</button>
</form>

<p v-if="sent" class="m-0 mt-2 text-xs text-muted">
{{ $t('organizations.invite.sent', { email: sent }) }}
</p>
</section>
</template>

<script setup lang="ts">
import { computed } from 'vue';

import { type OrganizationRole } from '../types/organization';

const { pending, inviteMember } = useOrganizations();
const { t } = useI18n();

const emailId = useId();
const roleId = useId();

// Written out as static `t()` calls rather than a dynamic `t(\`organizations.roles.${value}\`)`,
// since vue-i18n-extract's static usage report (`aube run i18n:report`) can't see interpolated
// keys and would otherwise report every role key as unused and fail the build.
const roleOptions = computed<{ value: OrganizationRole; label: string }[]>(() => [
{ value: 'member', label: t('organizations.roles.member') },
{ value: 'admin', label: t('organizations.roles.admin') },
{ value: 'owner', label: t('organizations.roles.owner') },
]);

const email = ref('');
const role = ref<OrganizationRole>('member');
const sent = ref('');

async function onSubmit() {
const invited = await inviteMember({ email: email.value, role: role.value });
// Only confirm once the server accepted it; the address is echoed back so the operator can spot
// a typo before chasing a missing invitation.
if (invited) {
sent.value = email.value;
email.value = '';
}
}
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
<template>
<section>
<h2 class="m-0 text-sm text-ink font-650">
{{ $t('organizations.members.title') }}
</h2>

<p v-if="members.length === 0" class="m-0 mt-2 text-xs text-muted">
{{ $t('organizations.members.empty') }}
</p>

<table v-else class="mt-3 w-full border-collapse text-left text-xs">
<thead>
<tr class="border-b border-line">
<th scope="col" class="py-2 label-upper">{{ $t('organizations.members.name') }}</th>
<th scope="col" class="py-2 label-upper">{{ $t('organizations.members.email') }}</th>
<th scope="col" class="py-2 label-upper">{{ $t('organizations.members.role') }}</th>
<th scope="col" class="py-2">
<span class="sr-only">{{ $t('organizations.members.actions') }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="member in members" :key="member.id" class="border-b border-line">
<td class="py-2 text-ink">{{ member.user.name }}</td>
<td class="py-2 text-muted">{{ member.user.email }}</td>
<td class="py-2 text-muted">{{ member.role }}</td>
<td class="py-2 text-right">
<button
class="focus-ring h-7 border border-line bg-raised px-2 text-xs text-ink transition hover:border-muted"
type="button"
:disabled="pending"
@click="removeMember(member.id)"
>
{{ $t('organizations.members.remove') }}
</button>
</td>
</tr>
</tbody>
</table>
</section>
</template>

<script setup lang="ts">
const { members, pending, removeMember, refreshMembers } = useOrganizations();

onMounted(() => {
void refreshMembers();
});
</script>
34 changes: 34 additions & 0 deletions apps/dashboard/app/modules/organizations/components/Switcher.vue
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
<template>
<div v-if="organizations.length > 0" class="border-t border-line p-4">
<label class="m-0 label-upper" :for="selectId">
{{ $t('organizations.switcher.label') }}
</label>
<select
:id="selectId"
class="focus-ring mt-2 h-8 w-full border border-line bg-raised px-2 text-xs text-ink"
:disabled="pending"
:value="activeOrganization?.id ?? ''"
@change="onChange"
>
<option v-for="organization in organizations" :key="organization.id" :value="organization.id">
{{ organization.name }}
</option>
</select>
</div>
</template>

<script setup lang="ts">
const { organizations, activeOrganization, pending, setActive, refresh } = useOrganizations();

const selectId = useId();

function onChange(event: Event) {
const { value } = event.target as HTMLSelectElement;
if (value) void setActive(value);
}

// The sidebar mounts once per session, so the list is fetched here rather than per page.
onMounted(() => {
void refresh();
});
</script>
Loading
Loading