Compare commits
9 Commits
6305d36b0d
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d4c4617d3 | |||
| 5ebf098316 | |||
| 8d5e2f8113 | |||
| ad79bf08ad | |||
| 2cbd650eca | |||
| 80715ba98b | |||
| ef12dffb62 | |||
| f379bba89a | |||
| 339f858f89 |
@@ -18,6 +18,52 @@ steps:
|
|||||||
branch: release
|
branch: release
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
image: bitnami/kubectl:latest
|
||||||
|
volumes:
|
||||||
|
- /home/future2/Studio_v2/woodpecker/kube/config:/tmp/kubeconfig:ro
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
commit="$(printf %.8s "$CI_COMMIT_SHA")"
|
||||||
|
job="studio-auth-migrate-$commit"
|
||||||
|
kubectl --kubeconfig=/tmp/kubeconfig -n studio-system delete job "$job" --ignore-not-found
|
||||||
|
cat <<'EOF' | sed "s/__JOB_NAME__/$job/g" | kubectl --kubeconfig=/tmp/kubeconfig apply -f -
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: __JOB_NAME__
|
||||||
|
namespace: studio-system
|
||||||
|
spec:
|
||||||
|
backoffLimit: 1
|
||||||
|
ttlSecondsAfterFinished: 600
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: migrate
|
||||||
|
image: localhost:5000/studio-auth-be:0.1.1
|
||||||
|
imagePullPolicy: Always
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -lc
|
||||||
|
- |
|
||||||
|
export DATABASE_URL="postgresql://$(printenv POSTGRES_USER):$(bun -e 'process.stdout.write(encodeURIComponent(process.env.POSTGRES_PASSWORD ?? ""))')@studio-auth-db.studio-system.svc.cluster.local:5432/$(printenv POSTGRES_DB)?schema=public"
|
||||||
|
bunx --no-install prisma migrate deploy
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: studio-auth-be-env
|
||||||
|
- secretRef:
|
||||||
|
name: studio-auth-db-secret
|
||||||
|
EOF
|
||||||
|
if ! kubectl --kubeconfig=/tmp/kubeconfig -n studio-system wait --for=condition=complete "job/$job" --timeout=180s; then
|
||||||
|
kubectl --kubeconfig=/tmp/kubeconfig -n studio-system logs "job/$job" --all-containers=true || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
when:
|
||||||
|
branch: release
|
||||||
|
event: push
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
image: bitnami/kubectl:latest
|
image: bitnami/kubectl:latest
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ Returns:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"accessToken": "...",
|
"accessToken": "...",
|
||||||
"accessTokenExpiresIn": 900,
|
"accessTokenExpiresIn": 43200,
|
||||||
"refreshToken": "...",
|
"refreshToken": "...",
|
||||||
"refreshTokenExpiresIn": 2592000,
|
"refreshTokenExpiresIn": 2592000,
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "launcher_manual_documents" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"is_published" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "launcher_manual_documents_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "launcher_manual_documents_key_key" ON "launcher_manual_documents"("key");
|
||||||
|
CREATE INDEX "launcher_manual_documents_is_published_sort_order_idx" ON "launcher_manual_documents"("is_published", "sort_order");
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "launcher_manual_documents"
|
||||||
|
ADD COLUMN "app_key" TEXT NOT NULL DEFAULT 'Launcher';
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS "launcher_manual_documents_is_published_sort_order_idx";
|
||||||
|
CREATE INDEX "launcher_manual_documents_app_key_is_published_sort_order_idx"
|
||||||
|
ON "launcher_manual_documents"("app_key", "is_published", "sort_order");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS "launcher_manual_documents_key_key";
|
||||||
|
ALTER TABLE "launcher_manual_documents" DROP COLUMN IF EXISTS "key";
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE "launcher_manual_documents"
|
||||||
|
ADD COLUMN IF NOT EXISTS "created_by_id" UUID,
|
||||||
|
ADD COLUMN IF NOT EXISTS "created_by_name" TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS "created_by_email" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "launcher_manual_documents_created_by_email_idx"
|
||||||
|
ON "launcher_manual_documents"("created_by_email");
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('public.organizations') IS NOT NULL THEN
|
||||||
|
ALTER TABLE "organizations"
|
||||||
|
ADD COLUMN IF NOT EXISTS "created_by_id" UUID,
|
||||||
|
ADD COLUMN IF NOT EXISTS "created_by_name" TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS "created_by_email" TEXT;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE "organizations"
|
||||||
|
ADD COLUMN "instructor_invite_code" TEXT;
|
||||||
|
|
||||||
|
UPDATE "organizations"
|
||||||
|
SET "instructor_invite_code" = UPPER(SUBSTRING(MD5("id"::text || CLOCK_TIMESTAMP()::text) FROM 1 FOR 6))
|
||||||
|
WHERE "instructor_invite_code" IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "organizations"
|
||||||
|
ALTER COLUMN "instructor_invite_code" SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "organizations_instructor_invite_code_key"
|
||||||
|
ON "organizations"("instructor_invite_code");
|
||||||
+25
-8
@@ -92,11 +92,26 @@ model License {
|
|||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
userLicenses UserLicense[]
|
userLicenses UserLicense[]
|
||||||
|
appPermissions LicenseAppPermission[]
|
||||||
|
|
||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
@@map("licenses")
|
@@map("licenses")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model LicenseAppPermission {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
licenseId String @map("license_id") @db.Uuid
|
||||||
|
appKey String @map("app_key")
|
||||||
|
canLaunch Boolean @default(true) @map("can_launch")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
license License @relation(fields: [licenseId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([licenseId, appKey])
|
||||||
|
@@index([appKey])
|
||||||
|
@@map("license_app_permissions")
|
||||||
|
}
|
||||||
|
|
||||||
model UserLicense {
|
model UserLicense {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
@@ -114,14 +129,16 @@ model UserLicense {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
name String
|
||||||
type OrganizationType @default(OTHER)
|
type OrganizationType @default(OTHER)
|
||||||
domain String?
|
domain String?
|
||||||
isActive Boolean @default(true) @map("is_active")
|
defaultLicenseCode String? @map("default_license_code")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
instructorInviteCode String @unique @map("instructor_invite_code")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
isActive Boolean @default(true) @map("is_active")
|
||||||
affiliations UserAffiliation[]
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
affiliations UserAffiliation[]
|
||||||
|
|
||||||
@@unique([name, type])
|
@@unique([name, type])
|
||||||
@@index([isActive, type])
|
@@index([isActive, type])
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it, mock } from 'bun:test'
|
||||||
|
|
||||||
|
const findFirst = mock(async (): Promise<unknown> => null)
|
||||||
|
mock.module('../prisma', () => ({ prisma: { userLicense: { findFirst } } }))
|
||||||
|
const { getLauncherAppPolicy } = await import('./license-policy')
|
||||||
|
|
||||||
|
describe('launcher license policy', () => {
|
||||||
|
it('returns the current license app policy', async () => {
|
||||||
|
findFirst.mockResolvedValueOnce({ license: { appPermissions: [{ appKey: 'LMS', canLaunch: true }, { appKey: 'OTHER', canLaunch: false }] } })
|
||||||
|
expect(await getLauncherAppPolicy('student')).toEqual({
|
||||||
|
defaultCanLaunch: true,
|
||||||
|
permissions: [{ appKey: 'LMS', canLaunch: true }, { appKey: 'OTHER', canLaunch: false }],
|
||||||
|
})
|
||||||
|
expect(findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
userId: 'student', startsAt: { lte: expect.any(Date) },
|
||||||
|
OR: [{ expiresAt: null }, { expiresAt: { gt: expect.any(Date) } }],
|
||||||
|
license: { isActive: true },
|
||||||
|
}),
|
||||||
|
orderBy: { startsAt: 'desc' },
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('denies by default without an active license', async () => {
|
||||||
|
findFirst.mockResolvedValueOnce(null)
|
||||||
|
expect(await getLauncherAppPolicy('student')).toEqual({ defaultCanLaunch: false, permissions: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { prisma } from '../prisma'
|
||||||
|
|
||||||
|
export async function getLauncherAppPolicy(userId: string) {
|
||||||
|
const now = new Date()
|
||||||
|
const current = await prisma.userLicense.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
startsAt: { lte: now },
|
||||||
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||||
|
license: { isActive: true }
|
||||||
|
},
|
||||||
|
orderBy: { startsAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
license: { select: { appPermissions: { select: { appKey: true, canLaunch: true } } } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
// Match the admin license policy: unspecified apps are allowed for active licenses.
|
||||||
|
defaultCanLaunch: Boolean(current),
|
||||||
|
permissions: current?.license.appPermissions ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
-1
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../storage/r2-public-files'
|
} from '../storage/r2-public-files'
|
||||||
import { hashPassword, verifyPassword } from '../security/password'
|
import { hashPassword, verifyPassword } from '../security/password'
|
||||||
import { redis } from '../redis'
|
import { redis } from '../redis'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
import { createOpaqueToken, hashToken } from '../security/tokens'
|
import { createOpaqueToken, hashToken } from '../security/tokens'
|
||||||
import type { AuthUser } from '../types'
|
import type { AuthUser } from '../types'
|
||||||
|
|
||||||
@@ -204,6 +205,52 @@ export async function createUser(input: { email: string; password: string; name?
|
|||||||
return toAuthUser(user)
|
return toAuthUser(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createInstructorUser(input: {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
name?: string | null
|
||||||
|
instructorInviteCode: string
|
||||||
|
}): Promise<AuthUser | null> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const organization = await tx.organization.findFirst({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
instructorInviteCode: input.instructorInviteCode.trim().toUpperCase()
|
||||||
|
},
|
||||||
|
select: { id: true, defaultLicenseCode: true }
|
||||||
|
})
|
||||||
|
if (!organization) return null
|
||||||
|
|
||||||
|
const requestedLicenseCode = organization.defaultLicenseCode ?? 'FREE'
|
||||||
|
const license = await tx.license.findFirst({
|
||||||
|
where: { code: requestedLicenseCode as 'FREE' | 'EDU' | 'PRO', isActive: true },
|
||||||
|
select: { code: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
email: input.email.toLowerCase(),
|
||||||
|
passwordHash: await hashPassword(input.password),
|
||||||
|
name: input.name?.trim() || null,
|
||||||
|
affiliations: {
|
||||||
|
create: { organizationId: organization.id, isPrimary: true }
|
||||||
|
},
|
||||||
|
appRoles: {
|
||||||
|
create: { appKey: 'LMS', roleCode: 'INSTRUCTOR' }
|
||||||
|
},
|
||||||
|
userLicenses: {
|
||||||
|
create: {
|
||||||
|
license: { connect: { code: license?.code ?? 'FREE' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: userSelect()
|
||||||
|
})
|
||||||
|
|
||||||
|
return toAuthUser(user)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function findOrCreateOAuthUser(input: {
|
export async function findOrCreateOAuthUser(input: {
|
||||||
provider: string
|
provider: string
|
||||||
providerUserId: string
|
providerUserId: string
|
||||||
@@ -487,11 +534,13 @@ export async function listOrganizations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrganization(input: { name: string; type: 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER'; domain?: string | null }) {
|
export async function createOrganization(input: { name: string; type: 'COMPANY' | 'SCHOOL' | 'GOVERNMENT' | 'LAB' | 'OTHER'; domain?: string | null }) {
|
||||||
|
const instructorInviteCode = await createInstructorInviteCode()
|
||||||
return prisma.organization.create({
|
return prisma.organization.create({
|
||||||
data: {
|
data: {
|
||||||
name: input.name.trim(),
|
name: input.name.trim(),
|
||||||
type: input.type,
|
type: input.type,
|
||||||
domain: input.domain?.trim() || null
|
domain: input.domain?.trim() || null,
|
||||||
|
instructorInviteCode
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -503,6 +552,20 @@ export async function createOrganization(input: { name: string; type: 'COMPANY'
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createInstructorInviteCode(): Promise<string> {
|
||||||
|
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||||
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||||
|
const code = Array.from(randomBytes(6), (byte) => alphabet[byte % alphabet.length]).join('')
|
||||||
|
const existing = await prisma.organization.findUnique({
|
||||||
|
where: { instructorInviteCode: code },
|
||||||
|
select: { id: true }
|
||||||
|
})
|
||||||
|
if (!existing) return code
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('failed_to_generate_instructor_invite_code')
|
||||||
|
}
|
||||||
|
|
||||||
export async function assignUserAffiliation(input: {
|
export async function assignUserAffiliation(input: {
|
||||||
userId: string
|
userId: string
|
||||||
organizationId: string
|
organizationId: string
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { randomInt } from 'crypto'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { config } from '../config'
|
import { config } from '../config'
|
||||||
import { sendEmailVerificationCode } from '../mail'
|
import { sendEmailVerificationCode } from '../mail'
|
||||||
|
import { prisma } from '../prisma'
|
||||||
import { verifyPassword } from '../security/password'
|
import { verifyPassword } from '../security/password'
|
||||||
import { signAccessToken, verifyAccessToken } from '../security/tokens'
|
import { signAccessToken, verifyAccessToken } from '../security/tokens'
|
||||||
import type { AuthUser } from '../types'
|
import type { AuthUser } from '../types'
|
||||||
import { authRequired, type AuthVariables } from './middleware'
|
import { authRequired, type AuthVariables } from './middleware'
|
||||||
|
import { getLauncherAppPolicy } from './license-policy'
|
||||||
import {
|
import {
|
||||||
clearLoginAttempts,
|
clearLoginAttempts,
|
||||||
assignUserAffiliation,
|
assignUserAffiliation,
|
||||||
@@ -19,12 +21,14 @@ import {
|
|||||||
createHandoffToken,
|
createHandoffToken,
|
||||||
createRefreshToken,
|
createRefreshToken,
|
||||||
createUser,
|
createUser,
|
||||||
|
createInstructorUser,
|
||||||
deleteUserProfileImage,
|
deleteUserProfileImage,
|
||||||
exchangeOAuthLoginToken,
|
exchangeOAuthLoginToken,
|
||||||
exchangeHandoffToken,
|
exchangeHandoffToken,
|
||||||
findOrCreateOAuthUser,
|
findOrCreateOAuthUser,
|
||||||
findUserProfileImage,
|
findUserProfileImage,
|
||||||
findUserByEmail,
|
findUserByEmail,
|
||||||
|
findUserById,
|
||||||
listOrganizations,
|
listOrganizations,
|
||||||
isLoginBlocked,
|
isLoginBlocked,
|
||||||
markLastLogin,
|
markLastLogin,
|
||||||
@@ -52,6 +56,10 @@ const signupSchema = z.object({
|
|||||||
verificationCode: z.string().regex(/^\d{6}$/)
|
verificationCode: z.string().regex(/^\d{6}$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const instructorSignupSchema = signupSchema.extend({
|
||||||
|
instructorInviteCode: z.string().trim().regex(/^[A-Za-z0-9]{6}$/)
|
||||||
|
})
|
||||||
|
|
||||||
const emailVerificationSendSchema = z.object({
|
const emailVerificationSendSchema = z.object({
|
||||||
email: z.string().email()
|
email: z.string().email()
|
||||||
})
|
})
|
||||||
@@ -148,6 +156,14 @@ const userResponse = (user: AuthUser) => ({
|
|||||||
|
|
||||||
const requireAdmin = (user: AuthUser): boolean => user.role === 'ADMIN'
|
const requireAdmin = (user: AuthUser): boolean => user.role === 'ADMIN'
|
||||||
|
|
||||||
|
const canManageLmsStudents = (user: AuthUser): boolean => (
|
||||||
|
user.role === 'ADMIN'
|
||||||
|
|| (user.appRoles ?? []).some((appRole) => (
|
||||||
|
appRole.appKey.toUpperCase() === 'LMS'
|
||||||
|
&& ['INSTRUCTOR', 'LMS_ADMIN'].includes(appRole.roleCode.toUpperCase())
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
const accessTokenResponse = async (user: AuthUser) => ({
|
const accessTokenResponse = async (user: AuthUser) => ({
|
||||||
accessToken: await signAccessToken(user),
|
accessToken: await signAccessToken(user),
|
||||||
accessTokenExpiresIn: config.accessTokenTtlSeconds,
|
accessTokenExpiresIn: config.accessTokenTtlSeconds,
|
||||||
@@ -606,6 +622,31 @@ authRoutes.post('/signup', async (c) => {
|
|||||||
return c.json(await accessTokenResponse(user), 201)
|
return c.json(await accessTokenResponse(user), 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
authRoutes.post('/instructor-signup', async (c) => {
|
||||||
|
const body = instructorSignupSchema.safeParse(await c.req.json().catch(() => null))
|
||||||
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||||
|
|
||||||
|
const email = body.data.email.toLowerCase()
|
||||||
|
const existingUser = await findUserByEmail(email)
|
||||||
|
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
|
||||||
|
|
||||||
|
const emailVerified = await verifyEmailVerificationCode(email, body.data.verificationCode)
|
||||||
|
if (!emailVerified) return c.json({ error: 'invalid_email_verification_code' }, 400)
|
||||||
|
|
||||||
|
const user = await createInstructorUser({
|
||||||
|
email,
|
||||||
|
password: body.data.password,
|
||||||
|
name: body.data.name,
|
||||||
|
instructorInviteCode: body.data.instructorInviteCode
|
||||||
|
})
|
||||||
|
if (!user) return c.json({ error: 'invalid_instructor_invite_code' }, 400)
|
||||||
|
|
||||||
|
const refreshToken = await createRefreshToken(user.id)
|
||||||
|
setRefreshCookie(c, refreshToken)
|
||||||
|
|
||||||
|
return c.json(await accessTokenResponse(user), 201)
|
||||||
|
})
|
||||||
|
|
||||||
authRoutes.post('/refresh', async (c) => {
|
authRoutes.post('/refresh', async (c) => {
|
||||||
const refreshToken = await readRefreshToken(c)
|
const refreshToken = await readRefreshToken(c)
|
||||||
if (!refreshToken) return c.json({ error: 'missing_refresh_token' }, 401)
|
if (!refreshToken) return c.json({ error: 'missing_refresh_token' }, 401)
|
||||||
@@ -646,6 +687,10 @@ authRoutes.post('/logout', async (c) => {
|
|||||||
return c.json({ ok: true })
|
return c.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
authRoutes.get('/launcher-app-policy', authRequired, async (c) => {
|
||||||
|
return c.json(await getLauncherAppPolicy(c.get('user').id))
|
||||||
|
})
|
||||||
|
|
||||||
authRoutes.get('/me', authRequired, (c) => {
|
authRoutes.get('/me', authRequired, (c) => {
|
||||||
return c.json({ user: userResponse(c.get('user')) })
|
return c.json({ user: userResponse(c.get('user')) })
|
||||||
})
|
})
|
||||||
@@ -772,6 +817,56 @@ authRoutes.post('/admin/users', authRequired, async (c) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
authRoutes.post('/instructor/students', authRequired, async (c) => {
|
||||||
|
const instructor = c.get('user')
|
||||||
|
if (!canManageLmsStudents(instructor)) return c.json({ error: 'forbidden' }, 403)
|
||||||
|
|
||||||
|
const body = adminCreateUserSchema.safeParse(await c.req.json().catch(() => null))
|
||||||
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||||
|
|
||||||
|
const organizationId = instructor.primaryAffiliation?.organizationId
|
||||||
|
if (instructor.role !== 'ADMIN' && (!organizationId || body.data.organizationId !== organizationId)) {
|
||||||
|
return c.json({ error: 'forbidden' }, 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = body.data.email.toLowerCase()
|
||||||
|
const existingUser = await findUserByEmail(email)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = existingUser ?? await createManagedUser({
|
||||||
|
email,
|
||||||
|
password: body.data.password,
|
||||||
|
name: body.data.name,
|
||||||
|
organizationId: body.data.organizationId,
|
||||||
|
department: body.data.department,
|
||||||
|
position: body.data.position,
|
||||||
|
licenseCode: body.data.licenseCode,
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.userAppRole.upsert({
|
||||||
|
where: {
|
||||||
|
userId_appKey_roleCode: {
|
||||||
|
userId: user.id,
|
||||||
|
appKey: 'LMS',
|
||||||
|
roleCode: 'STUDENT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
appKey: 'LMS',
|
||||||
|
roleCode: 'STUDENT',
|
||||||
|
assignedById: instructor.id,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolvedUser = await findUserById(user.id)
|
||||||
|
return c.json({ user: userResponse(resolvedUser ?? user) }, existingUser ? 200 : 201)
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: 'user_create_failed' }, 400)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
authRoutes.post('/admin/users/:userId/affiliations', authRequired, async (c) => {
|
authRoutes.post('/admin/users/:userId/affiliations', authRequired, async (c) => {
|
||||||
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ export const config = {
|
|||||||
redisUrl: required('REDIS_URL', 'redis://localhost:6380'),
|
redisUrl: required('REDIS_URL', 'redis://localhost:6380'),
|
||||||
jwtSecret: required('JWT_SECRET', 'dev-only-change-this-secret-at-least-32-bytes'),
|
jwtSecret: required('JWT_SECRET', 'dev-only-change-this-secret-at-least-32-bytes'),
|
||||||
jwtIssuer: process.env.JWT_ISSUER ?? 'atnmy-stdo-auth',
|
jwtIssuer: process.env.JWT_ISSUER ?? 'atnmy-stdo-auth',
|
||||||
accessTokenTtlSeconds: toInt(process.env.ACCESS_TOKEN_TTL_SECONDS, 60 * 60),
|
accessTokenTtlSeconds: toInt(process.env.ACCESS_TOKEN_TTL_SECONDS, 12 * 60 * 60),
|
||||||
refreshTokenTtlSeconds: toInt(process.env.REFRESH_TOKEN_TTL_SECONDS, 30 * 24 * 60 * 60),
|
refreshTokenTtlSeconds: toInt(process.env.REFRESH_TOKEN_TTL_SECONDS, 30 * 24 * 60 * 60),
|
||||||
handoffTokenTtlSeconds: toInt(process.env.HANDOFF_TOKEN_TTL_SECONDS, 60),
|
handoffTokenTtlSeconds: toInt(process.env.HANDOFF_TOKEN_TTL_SECONDS, 60),
|
||||||
maxFailedLoginAttempts: toInt(process.env.MAX_FAILED_LOGIN_ATTEMPTS, 5),
|
maxFailedLoginAttempts: toInt(process.env.MAX_FAILED_LOGIN_ATTEMPTS, 5),
|
||||||
|
|||||||
Reference in New Issue
Block a user