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
1 change: 1 addition & 0 deletions cli/internal/discovery/language.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ var ExtensionMap = map[string]string{
".swift": "swift",
".kt": "kotlin",
".scala": "scala",
".sol": "solidity",
".r": "r",
".lua": "lua",
".sh": "bash",
Expand Down
3 changes: 2 additions & 1 deletion doc/LANGUAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

cix uses tree-sitter (via `github.com/odvcencio/gotreesitter`) to extract semantic chunks (functions, classes, methods, types) from source code. Files in unsupported languages still get indexed via a sliding-window fallback — they're searchable, just without per-symbol granularity.

## Default language set (30)
## Default language set (31)

| ID | gotreesitter factory | Function | Class | Method | Type |
|---|---|:-:|:-:|:-:|:-:|
Expand Down Expand Up @@ -36,6 +36,7 @@ cix uses tree-sitter (via `github.com/odvcencio/gotreesitter`) to extract semant
| `fortran` | `FortranLanguage` | ✓ | ✓ | | |
| `haskell` | `HaskellLanguage` | ✓ | | | ✓ |
| `ocaml` | `OcamlLanguage` | ✓ | ✓ | | ✓ |
| `solidity` | `SolidityLanguage` | ✓ | ✓ | | ✓ |

The exact AST node types per language live in `server/internal/chunker/chunker.go` (`defaultRegistry`). File-extension mapping lives in `server/internal/langdetect/langdetect.go`.

Expand Down
151 changes: 151 additions & 0 deletions doc/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,93 @@ paths:
"404":
$ref: "#/components/responses/NotFound"

/api/v1/admin/users/{id}/reset-password:
parameters:
- name: id
in: path
required: true
schema:
type: string
post:
operationId: resetUserPassword
tags: [admin]
summary: Reset a user's password (admin only)
description: |
Sets a new admin-chosen temporary password and forces the user to
change it on next login (must_change_password=true), mirroring the
invite flow. Any admin may reset any user, including another admin.
The target user's existing sessions are revoked.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ResetUserPasswordRequest"
responses:
"200":
description: Password reset
content:
application/json:
schema:
$ref: "#/components/schemas/User"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"422":
$ref: "#/components/responses/Unprocessable"

/api/v1/admin/login-locks:
get:
operationId: listLoginLocks
tags: [admin]
summary: List active login rate-limit locks (admin only)
description: |
Returns the in-memory login-limiter counters that are currently at or
over their threshold — the IPs and (IP, email) pairs a login would be
rejected for right now with 429. State is per-process and self-heals as
the sliding windows expire, so this is a point-in-time snapshot.
responses:
"200":
description: Active locks
content:
application/json:
schema:
$ref: "#/components/schemas/LoginLockListResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"

/api/v1/admin/login-locks/reset:
post:
operationId: resetLoginLock
tags: [admin]
summary: Clear a login rate-limit lock (admin only)
description: |
Lifts a single lock surfaced by `GET /admin/login-locks`. `type` picks
the counter: `ip` clears the per-IP horizontal-sweep counter; `ip_email`
clears the per-(IP, email) counter for one account. The admin selects
the exact row to clear — the server never clears across keys.
Idempotent: clearing a key that is no longer locked returns 204.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ResetLoginLockRequest"
responses:
"204":
description: Lock cleared (or already absent)
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"422":
$ref: "#/components/responses/Unprocessable"

/api/v1/admin/runtime-config:
get:
operationId: getRuntimeConfig
Expand Down Expand Up @@ -3063,6 +3150,70 @@ components:
type: string
enum: [admin, user]

ResetUserPasswordRequest:
type: object
required: [new_password]
properties:
new_password:
type: string
minLength: 8
description: |
One-time password the user must change on next login.
The admin shares this out-of-band.

LoginLock:
type: object
required: [type, ip, attempts, limit, expires_at]
properties:
type:
type: string
enum: [ip, ip_email]
description: |
`ip` — the per-IP horizontal-sweep counter (many emails from one
source). `ip_email` — the per-(IP, email) counter for one account.
ip:
type: string
description: Source IP the lock is keyed on.
email:
type: string
description: Lower-cased email; present only for type `ip_email`.
attempts:
type: integer
description: Attempts currently inside the sliding window.
limit:
type: integer
description: The cap that tripped the lock.
expires_at:
type: string
format: date-time
description: RFC3339 — when the oldest attempt ages out and a slot frees.

LoginLockListResponse:
type: object
required: [locks, total]
properties:
locks:
type: array
items:
$ref: "#/components/schemas/LoginLock"
total:
type: integer

ResetLoginLockRequest:
type: object
required: [type, ip]
properties:
type:
type: string
enum: [ip, ip_email]
description: Which counter to clear — matches the `type` from the list.
ip:
type: string
description: Source IP of the lock to clear.
email:
type: string
description: Required when `type` is `ip_email`; ignored otherwise.

UpdateUserRequest:
type: object
properties:
Expand Down
4 changes: 4 additions & 0 deletions server/dashboard/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export type CreateGroupRequest = components['schemas']['CreateGroupRequest'];
export type UpdateGroupRequest = components['schemas']['UpdateGroupRequest'];
export type GroupIdListResponse = components['schemas']['GroupIdListResponse'];
export type UserWithStats = components['schemas']['UserWithStats'];
export type ResetUserPasswordRequest = components['schemas']['ResetUserPasswordRequest'];
export type LoginLock = components['schemas']['LoginLock'];
export type LoginLockListResponse = components['schemas']['LoginLockListResponse'];
export type ResetLoginLockRequest = components['schemas']['ResetLoginLockRequest'];
export type Session = components['schemas']['Session'];
export type ApiKey = components['schemas']['ApiKey'];
export type ApiKeyCreated = components['schemas']['ApiKeyCreated'];
Expand Down
58 changes: 58 additions & 0 deletions server/dashboard/src/modules/login-locks/LoginLocksPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { AlertCircle, ShieldCheck } from 'lucide-react';
import { ApiError } from '@/api/client';
import { Alert, AlertDescription, AlertTitle } from '@/ui/alert';
import { Skeleton } from '@/ui/skeleton';
import { LoginLocksTable } from './components/LoginLocksTable';
import { useLoginLocks } from './hooks';

export default function LoginLocksPage() {
const { data, error, isLoading } = useLoginLocks();

return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Login security</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Accounts and source IPs currently blocked by the login rate limiter
after too many failed attempts. Locks clear themselves as their
window expires — reset one to let a user back in immediately.
</p>
</div>
</header>

{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load login locks</AlertTitle>
<AlertDescription>
{error instanceof ApiError ? error.detail : String(error)}
</AlertDescription>
</Alert>
) : !data || data.locks.length === 0 ? (
<EmptyState />
) : (
<LoginLocksTable locks={data.locks} />
)}
</div>
);
}

function EmptyState() {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed py-16 text-center">
<ShieldCheck className="h-10 w-10 text-muted-foreground" />
<p className="text-base font-medium">No active login locks</p>
<p className="max-w-sm text-sm text-muted-foreground">
Nobody is currently rate-limited. Locks appear here automatically when
an account or IP exceeds the failed-login threshold.
</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { LoginLock } from '@/api/types';
import { Badge } from '@/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/ui/table';
import { formatDateTime, formatRelative } from '@/lib/formatDate';
import { ResetLockButton } from './ResetLockButton';

export function LoginLocksTable({ locks }: { locks: LoginLock[] }) {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">Type</TableHead>
<TableHead>IP</TableHead>
<TableHead>Email</TableHead>
<TableHead className="text-right">Attempts</TableHead>
<TableHead>Frees</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{locks.map((l) => (
<TableRow key={`${l.type}|${l.ip}|${l.email ?? ''}`}>
<TableCell>
{l.type === 'ip_email' ? (
<Badge variant="secondary" title="Per-account: this email from this IP">
IP + email
</Badge>
) : (
<Badge variant="outline" title="Per-IP: many emails from one source">
IP only
</Badge>
)}
</TableCell>
<TableCell className="font-mono text-xs">{l.ip}</TableCell>
<TableCell className="font-medium">{l.email || '—'}</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{l.attempts}/{l.limit}
</TableCell>
<TableCell
className="text-xs text-muted-foreground"
title={formatDateTime(l.expires_at)}
>
{formatRelative(l.expires_at)}
</TableCell>
<TableCell className="text-right">
<ResetLockButton lock={l} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Loader2, Unlock } from 'lucide-react';
import { toast } from 'sonner';
import { ApiError } from '@/api/client';
import type { LoginLock } from '@/api/types';
import { Button } from '@/ui/button';
import { useResetLoginLock } from '../hooks';

// One-click unlock for a single row. The lock carries its own exact key
// (type + ip + optional email), so we clear precisely that counter — never a
// scan across keys.
export function ResetLockButton({ lock }: { lock: LoginLock }) {
const reset = useResetLoginLock();

async function onReset() {
try {
await reset.mutateAsync({
type: lock.type,
ip: lock.ip,
email: lock.email,
});
toast.success('Login lock cleared', {
description:
lock.type === 'ip_email'
? `${lock.email} from ${lock.ip}`
: `IP ${lock.ip}`,
});
} catch (err) {
const detail = err instanceof ApiError ? err.detail : String(err);
toast.error('Could not clear lock', { description: detail });
}
}

return (
<Button
variant="ghost"
size="sm"
onClick={onReset}
disabled={reset.isPending}
title="Clear this lock now (lets the user log in again immediately)"
>
{reset.isPending ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Unlock className="mr-1 h-4 w-4" />
)}
Reset
</Button>
);
}
Loading
Loading