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
10 changes: 0 additions & 10 deletions .cursor/hooks.json

This file was deleted.

3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ yarn-error.log*
next-env.d.ts
references

.cursor/plans
.cursor/plans
.cursor/hooks/logs/
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"editor.defaultFormatter": "biomejs.biome"
},
"[markdown]": {
"editor.defaultFormatter": "yzhang.markdown-all-in-one"
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
},
"[mdx]": {
"editor.defaultFormatter": "biomejs.biome"
Expand Down
91 changes: 67 additions & 24 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { usePermissions } from "@/hooks/use-permissions"; // Custom hooks
```

**Current Implementation:**

- Auth module uses barrel exports via `@/auth` index file
- Database uses barrel exports via `@/db` index file
- UI components use direct imports: `@/components/ui/button`
Expand All @@ -74,33 +75,75 @@ import { usePermissions } from "@/hooks/use-permissions"; // Custom hooks
- **Components**: Place reusable components in `@/components`
- **UI**: Use shadcn/ui components from `@/ui/*`

## File Organization
## Environment Variables with t3-env

Portal uses `@t3-oss/env-nextjs` for type-safe, validated environment variable management. Each module that needs environment variables has its own `keys.ts` file.

### Pattern

1. **Module-level `keys.ts`**: Each lib module (auth, db, observability, xmpp, etc.) exports a `keys()` function that defines and validates its environment variables using Zod schemas.

```typescript
// src/lib/xmpp/keys.ts
import { z } from "zod";
import { createEnv } from "@t3-oss/env-nextjs";

export const keys = () =>
createEnv({
server: {
XMPP_DOMAIN: z.string().optional(),
PROSODY_REST_URL: z.url().optional(),
// ... other vars
},
runtimeEnv: {
XMPP_DOMAIN: process.env.XMPP_DOMAIN,
PROSODY_REST_URL: process.env.PROSODY_REST_URL,
// ... other vars
},
});
```

1. **Central `env.ts`**: The main `src/env.ts` file extends all module keys and provides a single source of truth.

```typescript
// src/env.ts
import { keys as auth } from "@/lib/auth/keys";
import { keys as database } from "@/lib/db/keys";
import { keys as observability } from "@/lib/observability/keys";
import { keys as xmpp } from "@/lib/xmpp/keys";

export const env = createEnv({
extends: [auth(), database(), observability(), xmpp()],
server: {},
client: {},
runtimeEnv: {},
});
```
src/
├── app/ # Next.js App Router
│ ├── (dashboard)/ # Protected dashboard routes
│ │ └── app/ # Main application routes
│ ├── api/ # API routes
│ ├── auth/ # Authentication pages
├── components/ # Reusable React components
│ ├── ui/ # shadcn/ui
│ └── layout/ # Layout components
├── lib/ # Core business logic
│ ├── auth/ # Authentication module
│ ├── db/ # Database configuration
│ ├── api/ # API client utilities
│ ├── config/ # Application configuration
│ ├── email/ # Email configuration
│ ├── routes/ # Route utilities and i18n routes
│ ├── seo/ # SEO utilities
│ └── utils/ # General utilities
├── hooks/ # Custom React hooks
├── i18n/ # Internationalization setup
├── styles/ # Global styles
└── proxy.ts # Development proxy configuration

1. **Usage in modules**: Modules import and use their own `keys()` function, not direct `process.env` access.

```typescript
// src/lib/xmpp/config.ts
import { keys } from "./keys";

const env = keys();
export const xmppConfig = {
domain: env.XMPP_DOMAIN || "xmpp.atl.chat",
prosody: {
restUrl: env.PROSODY_REST_URL,
// ...
},
};
```

### Benefits

- **Type safety**: Environment variables are typed and validated at runtime
- **Early error detection**: Invalid or missing required vars fail fast with clear error messages
- **Modularity**: Each module manages its own environment variables
- **No direct `process.env`**: All env access goes through validated `keys()` functions
- **Client/Server separation**: t3-env handles Next.js client/server boundary correctly

## Security Guidelines

- Never expose API keys in client code
Expand All @@ -120,4 +163,4 @@ src/
- Database setup uses Docker Compose (`docker compose up -d portal-db`)
- Available MCP tools: shadcn, Better Auth, llms.txt documentation, Next.js Devtools, GitHub, Sentry, Trigger.dev
- Use TanStack Query for all server state management
- Internationalization handled via next-intl with locale files in `locale/` directory
- Internationalization handled via next-intl with locale files in `locale/` directory
7 changes: 7 additions & 0 deletions locale/en/routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@
"label": "Settings"
}
},
"xmpp": {
"label": "XMPP",
"metadata": {
"title": "XMPP Account",
"description": "Manage your XMPP chat account"
}
},
"admin": {
"label": "Admin",
"metadata": {
Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"preinstall": "npx only-allow pnpm",
"deduplicate": "pnpm dedupe",
"prepare": "husky",
"dev": "pnpm dlx @react-grab/cursor@latest && next dev",
"dev": "next dev",
"dev:grab": "pnpm dlx @react-grab/cursor@latest && next dev",
"dev:turbo": "next dev --turbopack",
"dev:https": "next dev --experimental-https",
"scan": "NEXT_PUBLIC_REACT_SCAN_ENABLED=true next dev & pnpm dlx react-scan@latest localhost:3000",
Expand All @@ -17,7 +18,6 @@
"start": "next start",
"typegen": "next typegen",
"type-check": "tsc --noEmit",
"type-check:full": "next typegen && tsc --noEmit",
"check": "ultracite check",
"fix": "ultracite fix",
"info": "next info",
Expand Down Expand Up @@ -123,7 +123,6 @@
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"next-devtools-mcp": "^0.3.9",
"react-grab": "^0.0.98",
"shadcn": "^3.6.3",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
Expand Down
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

115 changes: 115 additions & 0 deletions scripts/create-prosody-oauth-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import "dotenv/config";

import { randomBytes, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";

import { db } from "@/lib/db";
import { oauthClient } from "@/lib/db/schema/oauth";

// ============================================================================
// Create Prosody OAuth Client Script
// ============================================================================
// This script registers Prosody XMPP server as an OAuth client in Better Auth.
// It creates a confidential client with password grant type for legacy XMPP
// client support.
//
// Usage:
// pnpm create-prosody-oauth-client
//
// Environment Variables:
// PROSODY_CLIENT_NAME - Name for the OAuth client (default: "Prosody XMPP Server")
// PROSODY_CLIENT_ID - Custom client ID (optional, auto-generated if not provided)
//
// Output:
// Prints the client_id and client_secret that should be set in Prosody
// configuration as PROSODY_OAUTH_CLIENT_ID and PROSODY_OAUTH_CLIENT_SECRET

async function createProsodyOAuthClient() {
const clientName = process.env.PROSODY_CLIENT_NAME || "Prosody XMPP Server";
const customClientId = process.env.PROSODY_CLIENT_ID;

try {
// Check if Prosody client already exists
const existingClient = await db
.select()
.from(oauthClient)
.where(eq(oauthClient.name, clientName))
.limit(1);

if (existingClient.length > 0) {
const client = existingClient[0];
console.log("ℹ️ Prosody OAuth client already exists:");
console.log(" Client ID:", client.clientId);
console.log(
" Client Secret:",
client.clientSecret ? "(set - not displayed for security)" : "(not set)"
);
console.log(" Name:", client.name);
console.log(" Disabled:", client.disabled);
return;
}

// Generate client ID and secret
// Use randomBytes for secure random generation
const generateRandomString = (length: number) =>
randomBytes(length).toString("base64url").slice(0, length);
const clientId = customClientId || `prosody_${generateRandomString(32)}`;
const clientSecret = generateRandomString(64);

// Create OAuth client in database
const [newClient] = await db
.insert(oauthClient)
.values({
id: randomUUID(),
clientId,
clientSecret,
name: clientName,
redirectUris: [], // Not needed for server-to-server auth
grantTypes: ["authorization_code", "password"], // Password grant for legacy clients
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

OAuth 2.1 password grant type deprecation status

💡 Result:

Short answer: The Resource Owner Password Credentials (password/ROPC) grant is deprecated and omitted from OAuth 2.1; the OAuth community’s Security Best Current Practice also disallows its use. Use Authorization Code + PKCE for user sign-in (or Client Credentials for machine-to-machine). (As of Jan 16, 2026.) Sources: [IETF OAuth 2.1 draft][1], oauth.net guidance on the password grant and OAuth 2.1 [2][3].

Sources

  1. IETF draft-ietf-oauth-v2-1 (OAuth 2.1) — ROPC omitted. [turn0search0]
  2. OAuth.net — “OAuth 2.0 Password Grant” (disallowed by Security BCP). [turn0search2]
  3. OAuth.net — OAuth 2.1 overview (password grant omitted; use auth code + PKCE). [turn0search1]

🏁 Script executed:

cd /repo && find . -name "create-prosody-oauth-client.ts" -type f

Repository: allthingslinux/portal

Length of output: 120


🏁 Script executed:

git ls-files | grep -E "(create-prosody|oauth)" | head -20

Repository: allthingslinux/portal

Length of output: 358


🏁 Script executed:

cat -n scripts/create-prosody-oauth-client.ts

Repository: allthingslinux/portal

Length of output: 4827


Verify rate limiting and secure storage are implemented for the password grant flow.

The password grant type is deprecated in OAuth 2.1 and disallowed by Security BCP. While this is documented as intentional for legacy XMPP client support, ensure compensating controls are in place:

  1. Rate limiting is enabled on the token endpoint to prevent credential abuse
  2. Client credentials (PROSODY_OAUTH_CLIENT_ID and PROSODY_OAUTH_CLIENT_SECRET) are stored securely in environment variables, not committed to version control
  3. Plan migration path to authorization_code + PKCE flow once Prosody XMPP client support allows
🤖 Prompt for AI Agents
In `@scripts/create-prosody-oauth-client.ts` at line 68, The diff enables the
deprecated "password" grant in grantTypes for create-prosody-oauth-client;
ensure compensating controls by (1) enforcing rate limiting on the token
endpoint that issues tokens for the password grant (implement or configure
per-client and per-IP throttling and brute-force protections where tokens are
minted), (2) verifying PROSODY_OAUTH_CLIENT_ID and PROSODY_OAUTH_CLIENT_SECRET
are loaded only from secure runtime secrets (environment variables or secret
manager) and not checked into VCS, and (3) add a short-term migration plan note
in the code/comments to move clients from the password grant to
authorization_code + PKCE once Prosody XMPP support allows (update grantTypes
usage where grantTypes: ["authorization_code","password"] is set and document
steps to rotate secrets and adopt PKCE).

tokenEndpointAuthMethod: "client_secret_post", // Prosody will use POST for auth
scopes: ["openid", "xmpp"], // Required scopes
skipConsent: true, // Trusted first-party client
public: false, // Confidential client
disabled: false,
})
.returning();

if (!newClient) {
throw new Error("Failed to create OAuth client: No client returned");
}

console.log("✅ Prosody OAuth client created successfully!");
console.log("");
console.log("📋 Configuration for Prosody:");
console.log(` PROSODY_OAUTH_CLIENT_ID=${newClient.clientId}`);
console.log(` PROSODY_OAUTH_CLIENT_SECRET=${newClient.clientSecret}`);
console.log("");
console.log("⚠️ Store these credentials securely!");
console.log(
" Add them to your Prosody environment variables or .env file."
);
} catch (error) {
console.error("❌ Failed to create Prosody OAuth client:");
if (error instanceof Error) {
console.error(" Error:", error.message);
if (error.stack) {
console.error(" Stack:", error.stack);
}
} else {
console.error(" Error:", error);
}
throw error;
}
}

// Run if called directly
if (require.main === module) {
createProsodyOAuthClient()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
}

export { createProsodyOAuthClient };
46 changes: 46 additions & 0 deletions src/app/(dashboard)/app/xmpp/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";

import { PageHeader } from "@/components/layout/page/page-header";
import { XmppAccountManagement } from "@/components/xmpp/xmpp-account-management";
import { getServerQueryClient } from "@/lib/api/hydration";
import { verifySession } from "@/lib/auth/dal";
import { getServerRouteResolver, routeConfig } from "@/lib/routes";
import { getRouteMetadata } from "@/lib/seo";

// Metadata is automatically generated from route config
export async function generateMetadata(): Promise<Metadata> {
const resolver = await getServerRouteResolver();
return getRouteMetadata("/app/xmpp", routeConfig, resolver);
}

// ============================================================================
// XMPP Account Management Page
// ============================================================================
// Page for managing XMPP accounts - create, view, update, and delete

export default async function XmppPage() {
// Verify user session
await verifySession();

// Create QueryClient for this request (isolated per request)
const queryClient = getServerQueryClient();

// Note: We don't prefetch XMPP account data here because:
// 1. It requires authentication cookies which aren't available server-side
// 2. Prefetching with null would mark the query as successful and block refetch for 30s
// The client-side hook will fetch the data when the component mounts

const resolver = await getServerRouteResolver();

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<div className="space-y-6">
<PageHeader pathname="/app/xmpp" resolver={resolver} />
<XmppAccountManagement />
</div>
</div>
</HydrationBoundary>
);
}
Loading