mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-26 14:54:55 +00:00
docs: nextjs,frontends,session (86 files)
This commit is contained in:
6
.github/workflows/quality-metrics.yml
vendored
6
.github/workflows/quality-metrics.yml
vendored
@@ -63,6 +63,11 @@ jobs:
|
||||
run: npx tsx ../../tools/check-maintainability.ts > maintainability-report.json
|
||||
continue-on-error: true
|
||||
|
||||
- name: Detect stub implementations
|
||||
id: stub-detection
|
||||
run: npx tsx ../../tools/detect-stub-implementations.ts > stub-report.json
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload quality reports
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
@@ -72,6 +77,7 @@ jobs:
|
||||
frontends/nextjs/complexity-report.json
|
||||
frontends/nextjs/function-metrics.json
|
||||
frontends/nextjs/maintainability-report.json
|
||||
frontends/nextjs/stub-report.json
|
||||
retention-days: 30
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -12,6 +12,20 @@ operations:
|
||||
errors:
|
||||
- CONFLICT: "Package with name and version already exists"
|
||||
- VALIDATION_ERROR: "Invalid package input"
|
||||
|
||||
create_many:
|
||||
description: "Bulk create package definitions"
|
||||
input:
|
||||
required: [items]
|
||||
optional: []
|
||||
output: integer
|
||||
acl_required: ["package:create"]
|
||||
validation:
|
||||
- semver_format: "Version must be valid semver"
|
||||
- name_version_unique: "Package name+version must be unique"
|
||||
errors:
|
||||
- CONFLICT: "Package with name and version already exists"
|
||||
- VALIDATION_ERROR: "Invalid package input"
|
||||
|
||||
read:
|
||||
description: "Get package by ID"
|
||||
@@ -35,6 +49,17 @@ operations:
|
||||
- NOT_FOUND: "Package not found"
|
||||
- CONFLICT: "Package name+version already exists"
|
||||
- VALIDATION_ERROR: "Invalid package update"
|
||||
|
||||
update_many:
|
||||
description: "Bulk update packages matching a filter"
|
||||
input:
|
||||
required: [filter, data]
|
||||
output: integer
|
||||
acl_required: ["package:update"]
|
||||
validation:
|
||||
- semver_format: "Version must be valid semver"
|
||||
errors:
|
||||
- VALIDATION_ERROR: "Invalid package update"
|
||||
|
||||
delete:
|
||||
description: "Delete package"
|
||||
@@ -44,6 +69,15 @@ operations:
|
||||
acl_required: ["package:delete"]
|
||||
errors:
|
||||
- NOT_FOUND: "Package not found"
|
||||
|
||||
delete_many:
|
||||
description: "Bulk delete packages matching a filter"
|
||||
input:
|
||||
required: [filter]
|
||||
output: integer
|
||||
acl_required: ["package:delete"]
|
||||
errors:
|
||||
- VALIDATION_ERROR: "Invalid delete filter"
|
||||
|
||||
list:
|
||||
description: "List packages with filtering and pagination"
|
||||
|
||||
@@ -13,6 +13,21 @@ operations:
|
||||
errors:
|
||||
- CONFLICT: "Username or email already exists"
|
||||
- VALIDATION_ERROR: "Invalid input data"
|
||||
|
||||
create_many:
|
||||
description: "Bulk create user accounts"
|
||||
input:
|
||||
required: [items]
|
||||
optional: []
|
||||
output: integer
|
||||
acl_required: ["user:create"]
|
||||
validation:
|
||||
- username_unique: "Usernames must be unique"
|
||||
- email_unique: "Emails must be unique"
|
||||
- email_format: "Each user must have a valid email address"
|
||||
errors:
|
||||
- CONFLICT: "Username or email already exists"
|
||||
- VALIDATION_ERROR: "Invalid user input"
|
||||
|
||||
read:
|
||||
description: "Get user by ID"
|
||||
@@ -39,6 +54,17 @@ operations:
|
||||
- NOT_FOUND: "User not found"
|
||||
- FORBIDDEN: "Cannot update other user"
|
||||
- CONFLICT: "Username or email already exists"
|
||||
|
||||
update_many:
|
||||
description: "Bulk update users matching a filter"
|
||||
input:
|
||||
required: [filter, data]
|
||||
output: integer
|
||||
acl_required: ["user:update"]
|
||||
validation:
|
||||
- no_role_escalation: "Cannot elevate roles in bulk updates"
|
||||
errors:
|
||||
- VALIDATION_ERROR: "Invalid update payload"
|
||||
|
||||
delete:
|
||||
description: "Delete user account"
|
||||
@@ -50,6 +76,15 @@ operations:
|
||||
errors:
|
||||
- NOT_FOUND: "User not found"
|
||||
- FORBIDDEN: "Insufficient permissions"
|
||||
|
||||
delete_many:
|
||||
description: "Bulk delete users matching a filter"
|
||||
input:
|
||||
required: [filter]
|
||||
output: integer
|
||||
acl_required: ["user:delete"]
|
||||
errors:
|
||||
- VALIDATION_ERROR: "Invalid delete filter"
|
||||
|
||||
list:
|
||||
description: "List users with filtering and pagination"
|
||||
|
||||
@@ -311,13 +311,21 @@ performance:
|
||||
|
||||
### Batch Operations
|
||||
|
||||
Use batch APIs for bulk inserts:
|
||||
Use batch APIs for bulk operations (return count of affected rows):
|
||||
|
||||
```cpp
|
||||
std::vector<CreateUserInput> users = {...};
|
||||
auto result = client.batchCreateUsers(users);
|
||||
auto created = client.batchCreateUsers(users);
|
||||
|
||||
std::vector<UpdateUserBatchItem> updates = {...};
|
||||
auto updated = client.batchUpdateUsers(updates);
|
||||
|
||||
std::vector<std::string> ids = {...};
|
||||
auto deleted = client.batchDeleteUsers(ids);
|
||||
```
|
||||
|
||||
Package equivalents are available via `batchCreatePackages`, `batchUpdatePackages`, and `batchDeletePackages`.
|
||||
|
||||
## Security Hardening
|
||||
|
||||
### 1. Run as Non-Root
|
||||
|
||||
@@ -32,6 +32,9 @@ public:
|
||||
Result<User> updateUser(const std::string& id, const UpdateUserInput& input);
|
||||
Result<bool> deleteUser(const std::string& id);
|
||||
Result<std::vector<User>> listUsers(const ListOptions& options);
|
||||
Result<int> batchCreateUsers(const std::vector<CreateUserInput>& inputs);
|
||||
Result<int> batchUpdateUsers(const std::vector<UpdateUserBatchItem>& updates);
|
||||
Result<int> batchDeleteUsers(const std::vector<std::string>& ids);
|
||||
|
||||
Result<PageView> createPage(const CreatePageInput& input);
|
||||
Result<PageView> getPage(const std::string& id);
|
||||
@@ -63,6 +66,9 @@ public:
|
||||
Result<Package> updatePackage(const std::string& id, const UpdatePackageInput& input);
|
||||
Result<bool> deletePackage(const std::string& id);
|
||||
Result<std::vector<Package>> listPackages(const ListOptions& options);
|
||||
Result<int> batchCreatePackages(const std::vector<CreatePackageInput>& inputs);
|
||||
Result<int> batchUpdatePackages(const std::vector<UpdatePackageBatchItem>& updates);
|
||||
Result<int> batchDeletePackages(const std::vector<std::string>& ids);
|
||||
|
||||
void close();
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ struct UpdateUserInput {
|
||||
std::optional<UserRole> role;
|
||||
};
|
||||
|
||||
struct UpdateUserBatchItem {
|
||||
std::string id;
|
||||
UpdateUserInput data;
|
||||
};
|
||||
|
||||
struct PageView {
|
||||
std::string id;
|
||||
std::string slug;
|
||||
@@ -194,6 +199,11 @@ struct UpdatePackageInput {
|
||||
std::optional<std::string> installed_by;
|
||||
};
|
||||
|
||||
struct UpdatePackageBatchItem {
|
||||
std::string id;
|
||||
UpdatePackageInput data;
|
||||
};
|
||||
|
||||
struct ListOptions {
|
||||
std::map<std::string, std::string> filter;
|
||||
std::map<std::string, std::string> sort;
|
||||
|
||||
@@ -259,6 +259,61 @@ Result<std::vector<User>> Client::listUsers(const ListOptions& options) {
|
||||
return Result<std::vector<User>>(std::vector<User>());
|
||||
}
|
||||
|
||||
Result<int> Client::batchCreateUsers(const std::vector<CreateUserInput>& inputs) {
|
||||
if (inputs.empty()) {
|
||||
return Result<int>(0);
|
||||
}
|
||||
|
||||
std::vector<std::string> created_ids;
|
||||
for (const auto& input : inputs) {
|
||||
auto result = createUser(input);
|
||||
if (result.isError()) {
|
||||
auto& store = getStore();
|
||||
for (const auto& id : created_ids) {
|
||||
store.users.erase(id);
|
||||
}
|
||||
return result.error();
|
||||
}
|
||||
created_ids.push_back(result.value().id);
|
||||
}
|
||||
|
||||
return Result<int>(static_cast<int>(created_ids.size()));
|
||||
}
|
||||
|
||||
Result<int> Client::batchUpdateUsers(const std::vector<UpdateUserBatchItem>& updates) {
|
||||
if (updates.empty()) {
|
||||
return Result<int>(0);
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
for (const auto& item : updates) {
|
||||
auto result = updateUser(item.id, item.data);
|
||||
if (result.isError()) {
|
||||
return result.error();
|
||||
}
|
||||
updated++;
|
||||
}
|
||||
|
||||
return Result<int>(updated);
|
||||
}
|
||||
|
||||
Result<int> Client::batchDeleteUsers(const std::vector<std::string>& ids) {
|
||||
if (ids.empty()) {
|
||||
return Result<int>(0);
|
||||
}
|
||||
|
||||
int deleted = 0;
|
||||
for (const auto& id : ids) {
|
||||
auto result = deleteUser(id);
|
||||
if (result.isError()) {
|
||||
return result.error();
|
||||
}
|
||||
deleted++;
|
||||
}
|
||||
|
||||
return Result<int>(deleted);
|
||||
}
|
||||
|
||||
Result<PageView> Client::createPage(const CreatePageInput& input) {
|
||||
// Validation
|
||||
if (!isValidSlug(input.slug)) {
|
||||
@@ -670,6 +725,13 @@ Result<Session> Client::getSession(const std::string& id) {
|
||||
return Error::notFound("Session not found: " + id);
|
||||
}
|
||||
|
||||
auto now = std::chrono::system_clock::now();
|
||||
if (it->second.expires_at <= now) {
|
||||
store.session_tokens.erase(it->second.token);
|
||||
store.sessions.erase(it);
|
||||
return Error::notFound("Session expired: " + id);
|
||||
}
|
||||
|
||||
return Result<Session>(it->second);
|
||||
}
|
||||
|
||||
@@ -741,6 +803,24 @@ Result<bool> Client::deleteSession(const std::string& id) {
|
||||
|
||||
Result<std::vector<Session>> Client::listSessions(const ListOptions& options) {
|
||||
auto& store = getStore();
|
||||
auto now = std::chrono::system_clock::now();
|
||||
std::vector<std::string> expired_sessions;
|
||||
|
||||
for (const auto& [id, session] : store.sessions) {
|
||||
if (session.expires_at <= now) {
|
||||
expired_sessions.push_back(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& id : expired_sessions) {
|
||||
auto expired_it = store.sessions.find(id);
|
||||
if (expired_it == store.sessions.end()) {
|
||||
continue;
|
||||
}
|
||||
store.session_tokens.erase(expired_it->second.token);
|
||||
store.sessions.erase(expired_it);
|
||||
}
|
||||
|
||||
std::vector<Session> sessions;
|
||||
|
||||
for (const auto& [id, session] : store.sessions) {
|
||||
@@ -1159,6 +1239,65 @@ Result<std::vector<Package>> Client::listPackages(const ListOptions& options) {
|
||||
return Result<std::vector<Package>>(std::vector<Package>());
|
||||
}
|
||||
|
||||
Result<int> Client::batchCreatePackages(const std::vector<CreatePackageInput>& inputs) {
|
||||
if (inputs.empty()) {
|
||||
return Result<int>(0);
|
||||
}
|
||||
|
||||
std::vector<std::string> created_ids;
|
||||
for (const auto& input : inputs) {
|
||||
auto result = createPackage(input);
|
||||
if (result.isError()) {
|
||||
auto& store = getStore();
|
||||
for (const auto& id : created_ids) {
|
||||
auto it = store.packages.find(id);
|
||||
if (it != store.packages.end()) {
|
||||
store.package_keys.erase(packageKey(it->second.name, it->second.version));
|
||||
store.packages.erase(it);
|
||||
}
|
||||
}
|
||||
return result.error();
|
||||
}
|
||||
created_ids.push_back(result.value().id);
|
||||
}
|
||||
|
||||
return Result<int>(static_cast<int>(created_ids.size()));
|
||||
}
|
||||
|
||||
Result<int> Client::batchUpdatePackages(const std::vector<UpdatePackageBatchItem>& updates) {
|
||||
if (updates.empty()) {
|
||||
return Result<int>(0);
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
for (const auto& item : updates) {
|
||||
auto result = updatePackage(item.id, item.data);
|
||||
if (result.isError()) {
|
||||
return result.error();
|
||||
}
|
||||
updated++;
|
||||
}
|
||||
|
||||
return Result<int>(updated);
|
||||
}
|
||||
|
||||
Result<int> Client::batchDeletePackages(const std::vector<std::string>& ids) {
|
||||
if (ids.empty()) {
|
||||
return Result<int>(0);
|
||||
}
|
||||
|
||||
int deleted = 0;
|
||||
for (const auto& id : ids) {
|
||||
auto result = deletePackage(id);
|
||||
if (result.isError()) {
|
||||
return result.error();
|
||||
}
|
||||
deleted++;
|
||||
}
|
||||
|
||||
return Result<int>(deleted);
|
||||
}
|
||||
|
||||
void Client::close() {
|
||||
// For in-memory implementation, optionally clear store
|
||||
// auto& store = getStore();
|
||||
|
||||
@@ -248,6 +248,63 @@ void test_list_users() {
|
||||
std::cout << " ✓ Pagination works (page 1, limit 2)" << std::endl;
|
||||
}
|
||||
|
||||
void test_user_batch_operations() {
|
||||
std::cout << "Testing user batch operations..." << std::endl;
|
||||
|
||||
dbal::ClientConfig config;
|
||||
config.adapter = "sqlite";
|
||||
config.database_url = ":memory:";
|
||||
dbal::Client client(config);
|
||||
|
||||
std::vector<dbal::CreateUserInput> users;
|
||||
dbal::CreateUserInput user1;
|
||||
user1.username = "batch_user_1";
|
||||
user1.email = "batch_user_1@example.com";
|
||||
users.push_back(user1);
|
||||
|
||||
dbal::CreateUserInput user2;
|
||||
user2.username = "batch_user_2";
|
||||
user2.email = "batch_user_2@example.com";
|
||||
user2.role = dbal::UserRole::Admin;
|
||||
users.push_back(user2);
|
||||
|
||||
auto createResult = client.batchCreateUsers(users);
|
||||
assert(createResult.isOk());
|
||||
assert(createResult.value() == 2);
|
||||
std::cout << " ✓ Batch created users" << std::endl;
|
||||
|
||||
dbal::ListOptions listOptions;
|
||||
listOptions.limit = 10;
|
||||
auto listResult = client.listUsers(listOptions);
|
||||
assert(listResult.isOk());
|
||||
assert(listResult.value().size() >= 2);
|
||||
|
||||
std::vector<dbal::UpdateUserBatchItem> updates;
|
||||
dbal::UpdateUserBatchItem update1;
|
||||
update1.id = listResult.value()[0].id;
|
||||
update1.data.email = "batch_updated_1@example.com";
|
||||
updates.push_back(update1);
|
||||
|
||||
dbal::UpdateUserBatchItem update2;
|
||||
update2.id = listResult.value()[1].id;
|
||||
update2.data.role = dbal::UserRole::God;
|
||||
updates.push_back(update2);
|
||||
|
||||
auto updateResult = client.batchUpdateUsers(updates);
|
||||
assert(updateResult.isOk());
|
||||
assert(updateResult.value() == 2);
|
||||
std::cout << " ✓ Batch updated users" << std::endl;
|
||||
|
||||
std::vector<std::string> ids;
|
||||
ids.push_back(listResult.value()[0].id);
|
||||
ids.push_back(listResult.value()[1].id);
|
||||
|
||||
auto deleteResult = client.batchDeleteUsers(ids);
|
||||
assert(deleteResult.isOk());
|
||||
assert(deleteResult.value() == 2);
|
||||
std::cout << " ✓ Batch deleted users" << std::endl;
|
||||
}
|
||||
|
||||
void test_page_crud() {
|
||||
std::cout << "Testing page CRUD operations..." << std::endl;
|
||||
|
||||
@@ -736,6 +793,66 @@ void test_package_validation() {
|
||||
std::cout << " ✓ Duplicate package version rejected" << std::endl;
|
||||
}
|
||||
|
||||
void test_package_batch_operations() {
|
||||
std::cout << "Testing package batch operations..." << std::endl;
|
||||
|
||||
dbal::ClientConfig config;
|
||||
config.adapter = "sqlite";
|
||||
config.database_url = ":memory:";
|
||||
dbal::Client client(config);
|
||||
|
||||
std::vector<dbal::CreatePackageInput> packages;
|
||||
dbal::CreatePackageInput package1;
|
||||
package1.name = "batch-package-1";
|
||||
package1.version = "1.0.0";
|
||||
package1.author = "MetaBuilder";
|
||||
package1.manifest = {{"entry", "index.lua"}};
|
||||
packages.push_back(package1);
|
||||
|
||||
dbal::CreatePackageInput package2;
|
||||
package2.name = "batch-package-2";
|
||||
package2.version = "2.0.0";
|
||||
package2.author = "MetaBuilder";
|
||||
package2.manifest = {{"entry", "chat.lua"}};
|
||||
packages.push_back(package2);
|
||||
|
||||
auto createResult = client.batchCreatePackages(packages);
|
||||
assert(createResult.isOk());
|
||||
assert(createResult.value() == 2);
|
||||
std::cout << " ✓ Batch created packages" << std::endl;
|
||||
|
||||
dbal::ListOptions listOptions;
|
||||
listOptions.limit = 10;
|
||||
auto listResult = client.listPackages(listOptions);
|
||||
assert(listResult.isOk());
|
||||
assert(listResult.value().size() >= 2);
|
||||
|
||||
std::vector<dbal::UpdatePackageBatchItem> updates;
|
||||
dbal::UpdatePackageBatchItem update1;
|
||||
update1.id = listResult.value()[0].id;
|
||||
update1.data.is_installed = true;
|
||||
updates.push_back(update1);
|
||||
|
||||
dbal::UpdatePackageBatchItem update2;
|
||||
update2.id = listResult.value()[1].id;
|
||||
update2.data.is_installed = true;
|
||||
updates.push_back(update2);
|
||||
|
||||
auto updateResult = client.batchUpdatePackages(updates);
|
||||
assert(updateResult.isOk());
|
||||
assert(updateResult.value() == 2);
|
||||
std::cout << " ✓ Batch updated packages" << std::endl;
|
||||
|
||||
std::vector<std::string> ids;
|
||||
ids.push_back(listResult.value()[0].id);
|
||||
ids.push_back(listResult.value()[1].id);
|
||||
|
||||
auto deleteResult = client.batchDeletePackages(ids);
|
||||
assert(deleteResult.isOk());
|
||||
assert(deleteResult.value() == 2);
|
||||
std::cout << " ✓ Batch deleted packages" << std::endl;
|
||||
}
|
||||
|
||||
void test_error_handling() {
|
||||
std::cout << "Testing comprehensive error handling..." << std::endl;
|
||||
|
||||
@@ -773,6 +890,7 @@ int main() {
|
||||
test_update_user();
|
||||
test_delete_user();
|
||||
test_list_users();
|
||||
test_user_batch_operations();
|
||||
test_page_crud();
|
||||
test_page_validation();
|
||||
test_workflow_crud();
|
||||
@@ -783,11 +901,12 @@ int main() {
|
||||
test_lua_script_validation();
|
||||
test_package_crud();
|
||||
test_package_validation();
|
||||
test_package_batch_operations();
|
||||
test_error_handling();
|
||||
|
||||
std::cout << std::endl;
|
||||
std::cout << "==================================================" << std::endl;
|
||||
std::cout << "✅ All 20 test suites passed!" << std::endl;
|
||||
std::cout << "✅ All 22 test suites passed!" << std::endl;
|
||||
std::cout << "==================================================" << std::endl;
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
|
||||
@@ -27,16 +27,16 @@
|
||||
"author": "MetaBuilder Contributors",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.3.1",
|
||||
"zod": "^3.22.4"
|
||||
"@prisma/client": "^6.19.1",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"eslint": "^8.56.0",
|
||||
"prettier": "^3.1.1",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^1.1.0"
|
||||
"@types/node": "^25.0.3",
|
||||
"eslint": "^9.39.2",
|
||||
"prettier": "^3.7.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.16",
|
||||
"@vitest/coverage-v8": "^4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,24 @@ export class ACLAdapter implements DBALAdapter {
|
||||
this.auditLog = options?.auditLog ?? true
|
||||
}
|
||||
|
||||
private resolvePermissionOperation(operation: string): string {
|
||||
switch (operation) {
|
||||
case 'findFirst':
|
||||
case 'findByField':
|
||||
return 'read'
|
||||
case 'createMany':
|
||||
return 'create'
|
||||
case 'updateByField':
|
||||
case 'updateMany':
|
||||
return 'update'
|
||||
case 'deleteByField':
|
||||
case 'deleteMany':
|
||||
return 'delete'
|
||||
default:
|
||||
return operation
|
||||
}
|
||||
}
|
||||
|
||||
private checkPermission(entity: string, operation: string): void {
|
||||
const matchingRules = this.rules.filter(rule =>
|
||||
rule.entity === entity &&
|
||||
@@ -244,6 +262,187 @@ export class ACLAdapter implements DBALAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
|
||||
const permissionOperation = this.resolvePermissionOperation('findFirst')
|
||||
this.checkPermission(entity, permissionOperation)
|
||||
|
||||
try {
|
||||
const result = await this.baseAdapter.findFirst(entity, filter)
|
||||
if (result) {
|
||||
this.checkRowLevelAccess(entity, permissionOperation, result as Record<string, unknown>)
|
||||
}
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'findFirst', true)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'findFirst', false, error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
|
||||
const permissionOperation = this.resolvePermissionOperation('findByField')
|
||||
this.checkPermission(entity, permissionOperation)
|
||||
|
||||
try {
|
||||
const result = await this.baseAdapter.findByField(entity, field, value)
|
||||
if (result) {
|
||||
this.checkRowLevelAccess(entity, permissionOperation, result as Record<string, unknown>)
|
||||
}
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'findByField', true)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'findByField', false, error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async upsert(
|
||||
entity: string,
|
||||
uniqueField: string,
|
||||
uniqueValue: unknown,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const existing = await this.baseAdapter.findByField(entity, uniqueField, uniqueValue)
|
||||
if (existing) {
|
||||
this.checkPermission(entity, 'update')
|
||||
this.checkRowLevelAccess(entity, 'update', existing as Record<string, unknown>)
|
||||
} else {
|
||||
this.checkPermission(entity, 'create')
|
||||
}
|
||||
|
||||
const result = await this.baseAdapter.upsert(entity, uniqueField, uniqueValue, createData, updateData)
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'upsert', true)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'upsert', false, error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
|
||||
const permissionOperation = this.resolvePermissionOperation('updateByField')
|
||||
this.checkPermission(entity, permissionOperation)
|
||||
|
||||
const existing = await this.baseAdapter.findByField(entity, field, value)
|
||||
if (existing) {
|
||||
this.checkRowLevelAccess(entity, permissionOperation, existing as Record<string, unknown>)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.baseAdapter.updateByField(entity, field, value, data)
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'updateByField', true)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'updateByField', false, error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
|
||||
const permissionOperation = this.resolvePermissionOperation('deleteByField')
|
||||
this.checkPermission(entity, permissionOperation)
|
||||
|
||||
const existing = await this.baseAdapter.findByField(entity, field, value)
|
||||
if (existing) {
|
||||
this.checkRowLevelAccess(entity, permissionOperation, existing as Record<string, unknown>)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.baseAdapter.deleteByField(entity, field, value)
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'deleteByField', true)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'deleteByField', false, error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
|
||||
const permissionOperation = this.resolvePermissionOperation('createMany')
|
||||
this.checkPermission(entity, permissionOperation)
|
||||
|
||||
try {
|
||||
const result = await this.baseAdapter.createMany(entity, data)
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'createMany', true)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'createMany', false, error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
|
||||
const permissionOperation = this.resolvePermissionOperation('updateMany')
|
||||
this.checkPermission(entity, permissionOperation)
|
||||
|
||||
const listResult = await this.baseAdapter.list(entity, { filter })
|
||||
for (const item of listResult.data) {
|
||||
this.checkRowLevelAccess(entity, permissionOperation, item as Record<string, unknown>)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.baseAdapter.updateMany(entity, filter, data)
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'updateMany', true)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'updateMany', false, error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
|
||||
const permissionOperation = this.resolvePermissionOperation('deleteMany')
|
||||
this.checkPermission(entity, permissionOperation)
|
||||
|
||||
const listResult = await this.baseAdapter.list(entity, { filter })
|
||||
for (const item of listResult.data) {
|
||||
this.checkRowLevelAccess(entity, permissionOperation, item as Record<string, unknown>)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.baseAdapter.deleteMany(entity, filter)
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'deleteMany', true)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (this.auditLog) {
|
||||
this.logAudit(entity, 'deleteMany', false, error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<AdapterCapabilities> {
|
||||
return this.baseAdapter.getCapabilities()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface DBALAdapter {
|
||||
deleteByField(entity: string, field: string, value: unknown): Promise<boolean>
|
||||
deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number>
|
||||
createMany(entity: string, data: Record<string, unknown>[]): Promise<number>
|
||||
updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number>
|
||||
|
||||
getCapabilities(): Promise<AdapterCapabilities>
|
||||
close(): Promise<void>
|
||||
|
||||
@@ -196,6 +196,19 @@ export class PrismaAdapter implements DBALAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
const where = this.buildWhereClause(filter)
|
||||
const result = await this.withTimeout(
|
||||
model.updateMany({ where: where as never, data: data as never })
|
||||
)
|
||||
return result.count
|
||||
} catch (error) {
|
||||
throw this.handleError(error, 'updateMany', entity)
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
|
||||
try {
|
||||
const model = this.getModel(entity)
|
||||
|
||||
@@ -129,6 +129,44 @@ export class WebSocketBridge implements DBALAdapter {
|
||||
return this.call('list', entity, options) as Promise<ListResult<unknown>>
|
||||
}
|
||||
|
||||
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
|
||||
return this.call('findFirst', entity, filter) as Promise<unknown | null>
|
||||
}
|
||||
|
||||
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
|
||||
return this.call('findByField', entity, field, value) as Promise<unknown | null>
|
||||
}
|
||||
|
||||
async upsert(
|
||||
entity: string,
|
||||
uniqueField: string,
|
||||
uniqueValue: unknown,
|
||||
createData: Record<string, unknown>,
|
||||
updateData: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
return this.call('upsert', entity, uniqueField, uniqueValue, createData, updateData)
|
||||
}
|
||||
|
||||
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
|
||||
return this.call('updateByField', entity, field, value, data)
|
||||
}
|
||||
|
||||
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
|
||||
return this.call('deleteByField', entity, field, value) as Promise<boolean>
|
||||
}
|
||||
|
||||
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
|
||||
return this.call('deleteMany', entity, filter) as Promise<number>
|
||||
}
|
||||
|
||||
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
|
||||
return this.call('createMany', entity, data) as Promise<number>
|
||||
}
|
||||
|
||||
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
|
||||
return this.call('updateMany', entity, filter, data) as Promise<number>
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<AdapterCapabilities> {
|
||||
return this.call('getCapabilities') as Promise<AdapterCapabilities>
|
||||
}
|
||||
|
||||
@@ -164,6 +164,66 @@ export class DBALClient {
|
||||
list: async (options?: ListOptions): Promise<ListResult<User>> => {
|
||||
return this.adapter.list('User', options) as Promise<ListResult<User>>
|
||||
},
|
||||
createMany: async (data: Array<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>): Promise<number> => {
|
||||
if (!data || data.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const validationErrors = data.flatMap((item, index) =>
|
||||
validateUserCreate(item).map(error => ({ field: `users[${index}]`, error }))
|
||||
)
|
||||
if (validationErrors.length > 0) {
|
||||
throw DBALError.validationError('Invalid user batch', validationErrors)
|
||||
}
|
||||
|
||||
try {
|
||||
return this.adapter.createMany('User', data as Record<string, unknown>[])
|
||||
} catch (error) {
|
||||
if (error instanceof DBALError && error.code === 409) {
|
||||
throw DBALError.conflict('Username or email already exists')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
updateMany: async (filter: Record<string, unknown>, data: Partial<User>): Promise<number> => {
|
||||
if (!filter || Object.keys(filter).length === 0) {
|
||||
throw DBALError.validationError('Bulk update requires a filter', [
|
||||
{ field: 'filter', error: 'Filter is required' },
|
||||
])
|
||||
}
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
throw DBALError.validationError('Bulk update requires data', [
|
||||
{ field: 'data', error: 'Update data is required' },
|
||||
])
|
||||
}
|
||||
|
||||
const validationErrors = validateUserUpdate(data)
|
||||
if (validationErrors.length > 0) {
|
||||
throw DBALError.validationError(
|
||||
'Invalid user update data',
|
||||
validationErrors.map(error => ({ field: 'user', error }))
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return this.adapter.updateMany('User', filter, data as Record<string, unknown>)
|
||||
} catch (error) {
|
||||
if (error instanceof DBALError && error.code === 409) {
|
||||
throw DBALError.conflict('Username or email already exists')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
deleteMany: async (filter: Record<string, unknown>): Promise<number> => {
|
||||
if (!filter || Object.keys(filter).length === 0) {
|
||||
throw DBALError.validationError('Bulk delete requires a filter', [
|
||||
{ field: 'filter', error: 'Filter is required' },
|
||||
])
|
||||
}
|
||||
|
||||
return this.adapter.deleteMany('User', filter)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,6 +642,66 @@ export class DBALClient {
|
||||
list: async (options?: ListOptions): Promise<ListResult<Package>> => {
|
||||
return this.adapter.list('Package', options) as Promise<ListResult<Package>>
|
||||
},
|
||||
createMany: async (data: Array<Omit<Package, 'id' | 'createdAt' | 'updatedAt'>>): Promise<number> => {
|
||||
if (!data || data.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const validationErrors = data.flatMap((item, index) =>
|
||||
validatePackageCreate(item).map(error => ({ field: `packages[${index}]`, error }))
|
||||
)
|
||||
if (validationErrors.length > 0) {
|
||||
throw DBALError.validationError('Invalid package batch', validationErrors)
|
||||
}
|
||||
|
||||
try {
|
||||
return this.adapter.createMany('Package', data as Record<string, unknown>[])
|
||||
} catch (error) {
|
||||
if (error instanceof DBALError && error.code === 409) {
|
||||
throw DBALError.conflict('Package name+version already exists')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
updateMany: async (filter: Record<string, unknown>, data: Partial<Package>): Promise<number> => {
|
||||
if (!filter || Object.keys(filter).length === 0) {
|
||||
throw DBALError.validationError('Bulk update requires a filter', [
|
||||
{ field: 'filter', error: 'Filter is required' },
|
||||
])
|
||||
}
|
||||
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
throw DBALError.validationError('Bulk update requires data', [
|
||||
{ field: 'data', error: 'Update data is required' },
|
||||
])
|
||||
}
|
||||
|
||||
const validationErrors = validatePackageUpdate(data)
|
||||
if (validationErrors.length > 0) {
|
||||
throw DBALError.validationError(
|
||||
'Invalid package update data',
|
||||
validationErrors.map(error => ({ field: 'package', error }))
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return this.adapter.updateMany('Package', filter, data as Record<string, unknown>)
|
||||
} catch (error) {
|
||||
if (error instanceof DBALError && error.code === 409) {
|
||||
throw DBALError.conflict('Package name+version already exists')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
deleteMany: async (filter: Record<string, unknown>): Promise<number> => {
|
||||
if (!filter || Object.keys(filter).length === 0) {
|
||||
throw DBALError.validationError('Bulk delete requires a filter', [
|
||||
{ field: 'filter', error: 'Filter is required' },
|
||||
])
|
||||
}
|
||||
|
||||
return this.adapter.deleteMany('Package', filter)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
141
dbal/ts/tests/core/client-batch.test.ts
Normal file
141
dbal/ts/tests/core/client-batch.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DBALClient } from '../../src/core/client'
|
||||
import { DBALErrorCode } from '../../src/core/errors'
|
||||
|
||||
const mockAdapter = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
read: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
list: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findByField: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
updateByField: vi.fn(),
|
||||
deleteByField: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
getCapabilities: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/adapters/prisma-adapter', () => ({
|
||||
PrismaAdapter: vi.fn(() => mockAdapter),
|
||||
}))
|
||||
|
||||
const baseConfig = {
|
||||
mode: 'development' as const,
|
||||
adapter: 'prisma' as const,
|
||||
database: { url: 'file:memory' },
|
||||
}
|
||||
|
||||
const userBatch = [
|
||||
{ username: 'alpha', email: 'alpha@example.com', role: 'user' as const },
|
||||
{ username: 'beta', email: 'beta@example.com', role: 'admin' as const },
|
||||
]
|
||||
|
||||
const packageBatch = [
|
||||
{
|
||||
name: 'forum',
|
||||
version: '1.0.0',
|
||||
author: 'MetaBuilder',
|
||||
manifest: { entry: 'index.lua' },
|
||||
isInstalled: false,
|
||||
},
|
||||
{
|
||||
name: 'chat',
|
||||
version: '2.1.0',
|
||||
author: 'MetaBuilder',
|
||||
manifest: { entry: 'chat.lua' },
|
||||
isInstalled: true,
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
Object.values(mockAdapter).forEach(value => {
|
||||
if (typeof value === 'function' && 'mockReset' in value) {
|
||||
value.mockReset()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DBALClient batch operations', () => {
|
||||
it('creates users in bulk via adapter', async () => {
|
||||
mockAdapter.createMany.mockResolvedValue(2)
|
||||
|
||||
const client = new DBALClient(baseConfig)
|
||||
const result = await client.users.createMany(userBatch)
|
||||
|
||||
expect(mockAdapter.createMany).toHaveBeenCalledWith('User', userBatch)
|
||||
expect(result).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects bulk user create with invalid data', async () => {
|
||||
const client = new DBALClient(baseConfig)
|
||||
|
||||
await expect(client.users.createMany([
|
||||
{ username: '', email: 'bad', role: 'user' as const },
|
||||
])).rejects.toMatchObject({ code: DBALErrorCode.VALIDATION_ERROR })
|
||||
|
||||
expect(mockAdapter.createMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates users in bulk with a filter', async () => {
|
||||
mockAdapter.updateMany.mockResolvedValue(1)
|
||||
|
||||
const client = new DBALClient(baseConfig)
|
||||
const result = await client.users.updateMany({ role: 'user' }, { role: 'admin' })
|
||||
|
||||
expect(mockAdapter.updateMany).toHaveBeenCalledWith('User', { role: 'user' }, { role: 'admin' })
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects bulk user update without a filter', async () => {
|
||||
const client = new DBALClient(baseConfig)
|
||||
|
||||
await expect(client.users.updateMany({}, { role: 'admin' })).rejects.toMatchObject({
|
||||
code: DBALErrorCode.VALIDATION_ERROR,
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes users in bulk with a filter', async () => {
|
||||
mockAdapter.deleteMany.mockResolvedValue(2)
|
||||
|
||||
const client = new DBALClient(baseConfig)
|
||||
const result = await client.users.deleteMany({ role: 'user' })
|
||||
|
||||
expect(mockAdapter.deleteMany).toHaveBeenCalledWith('User', { role: 'user' })
|
||||
expect(result).toBe(2)
|
||||
})
|
||||
|
||||
it('creates packages in bulk via adapter', async () => {
|
||||
mockAdapter.createMany.mockResolvedValue(2)
|
||||
|
||||
const client = new DBALClient(baseConfig)
|
||||
const result = await client.packages.createMany(packageBatch)
|
||||
|
||||
expect(mockAdapter.createMany).toHaveBeenCalledWith('Package', packageBatch)
|
||||
expect(result).toBe(2)
|
||||
})
|
||||
|
||||
it('updates packages in bulk with a filter', async () => {
|
||||
mockAdapter.updateMany.mockResolvedValue(3)
|
||||
|
||||
const client = new DBALClient(baseConfig)
|
||||
const result = await client.packages.updateMany({ isInstalled: false }, { isInstalled: true })
|
||||
|
||||
expect(mockAdapter.updateMany).toHaveBeenCalledWith('Package', { isInstalled: false }, { isInstalled: true })
|
||||
expect(result).toBe(3)
|
||||
})
|
||||
|
||||
it('deletes packages in bulk with a filter', async () => {
|
||||
mockAdapter.deleteMany.mockResolvedValue(1)
|
||||
|
||||
const client = new DBALClient(baseConfig)
|
||||
const result = await client.packages.deleteMany({ name: 'forum' })
|
||||
|
||||
expect(mockAdapter.deleteMany).toHaveBeenCalledWith('Package', { name: 'forum' })
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ const mockAdapter = vi.hoisted(() => ({
|
||||
deleteByField: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
getCapabilities: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -115,6 +115,17 @@ const result = await dbalQuery({
|
||||
| **Validation** | Schema | Custom rules |
|
||||
| **Performance** | Good | Optimized |
|
||||
|
||||
### Primary Key Mapping
|
||||
|
||||
DBAL adapters normalize primary key lookups for Prisma models that use non-`id` keys. The adapter maps the entity name to its primary key field so standard `read`/`update`/`delete` calls work correctly.
|
||||
|
||||
Non-`id` primary keys in this repo:
|
||||
- `Credential.username`
|
||||
- `InstalledPackage.packageId`
|
||||
- `PackageData.packageId`
|
||||
- `PasswordResetToken.username`
|
||||
- `SystemConfig.key`
|
||||
|
||||
## Database Operations
|
||||
|
||||
### Create (Insert)
|
||||
|
||||
@@ -18,6 +18,8 @@ app/api/
|
||||
│ └── route.ts
|
||||
└── auth/
|
||||
├── login/route.ts
|
||||
├── register/route.ts
|
||||
├── session/route.ts
|
||||
└── logout/route.ts
|
||||
```
|
||||
|
||||
@@ -75,6 +77,70 @@ Content-Type: application/json
|
||||
DELETE /api/users/{id}
|
||||
```
|
||||
|
||||
## Auth API (Session-backed)
|
||||
|
||||
Authentication uses a database-backed session with an httpOnly cookie (`mb_session`).
|
||||
Sessions default to a 7 day TTL and are refreshed on `/api/auth/session`.
|
||||
|
||||
### Login
|
||||
|
||||
```
|
||||
POST /api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"identifier": "alice@example.com",
|
||||
"password": "s3cret"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```
|
||||
{
|
||||
"user": {
|
||||
"id": "user_123",
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"role": "user",
|
||||
"createdAt": 1715612345678
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Register
|
||||
|
||||
```
|
||||
POST /api/auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"password": "s3cret"
|
||||
}
|
||||
```
|
||||
|
||||
### Session Check
|
||||
|
||||
```
|
||||
GET /api/auth/session
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```
|
||||
{
|
||||
"user": null
|
||||
}
|
||||
```
|
||||
|
||||
### Logout
|
||||
|
||||
```
|
||||
POST /api/auth/logout
|
||||
```
|
||||
|
||||
## Creating an API Endpoint
|
||||
|
||||
### 1. Create Route File
|
||||
|
||||
@@ -324,7 +324,8 @@ If you're inside the app, the GitHub Actions Monitor (Level 1) can fetch run log
|
||||
download them locally, and surface common error signals. For private repos or higher
|
||||
rate limits, set `GITHUB_TOKEN`. To target a different repository, set
|
||||
`GITHUB_OWNER` and `GITHUB_REPO` (used by the `/api/github/actions/runs` and
|
||||
`/api/github/actions/runs/{runId}/logs` endpoints).
|
||||
`/api/github/actions/runs/{runId}/logs` endpoints). The logs endpoint also accepts
|
||||
`jobLimit` and `includeLogs` query parameters.
|
||||
|
||||
---
|
||||
|
||||
|
||||
1764
frontends/nextjs/package-lock.json
generated
1764
frontends/nextjs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -56,26 +56,26 @@
|
||||
"@aws-sdk/client-s3": "^3.958.0",
|
||||
"@aws-sdk/lib-storage": "^3.958.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.958.0",
|
||||
"@emotion/react": "^11.13.5",
|
||||
"@emotion/styled": "^11.13.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@github/spark": ">=0.43.1 <1",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mui/icons-material": "^6.3.1",
|
||||
"@mui/material": "^6.3.1",
|
||||
"@mui/x-data-grid": "^7.24.2",
|
||||
"@mui/x-date-pickers": "^7.24.1",
|
||||
"@mui/icons-material": "^7.3.6",
|
||||
"@mui/material": "^7.3.6",
|
||||
"@mui/x-data-grid": "^8.23.0",
|
||||
"@mui/x-date-pickers": "^8.23.0",
|
||||
"@next/third-parties": "^16.1.1",
|
||||
"@octokit/core": "^6.1.4",
|
||||
"@octokit/core": "^7.0.6",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@prisma/client": "^6.19.1",
|
||||
"@tanstack/react-query": "^5.83.1",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@types/jszip": "^3.4.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fengari-interop": "^0.1.4",
|
||||
"fengari-web": "^0.1.4",
|
||||
"framer-motion": "^12.6.2",
|
||||
"framer-motion": "^12.23.26",
|
||||
"jszip": "^3.10.1",
|
||||
"marked": "^17.0.1",
|
||||
"next": "16.1.1",
|
||||
@@ -84,11 +84,11 @@
|
||||
"react-dom": "19.2.3",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"recharts": "^2.15.1",
|
||||
"recharts": "^3.6.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"sonner": "^2.0.7",
|
||||
"three": "^0.175.0",
|
||||
"three": "^0.182.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
@@ -98,15 +98,15 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^27.3.0",
|
||||
"prisma": "^6.19.1",
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { User } from '@/lib/level-types'
|
||||
import { fetchSession } from '@/lib/auth/api/fetch-session'
|
||||
import { login as loginRequest } from '@/lib/auth/api/login'
|
||||
import { logout as logoutRequest } from '@/lib/auth/api/logout'
|
||||
import { register as registerRequest } from '@/lib/auth/api/register'
|
||||
import { AuthContext } from './auth-context'
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -17,11 +21,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/session')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUser(data.user)
|
||||
}
|
||||
const sessionUser = await fetchSession()
|
||||
setUser(sessionUser)
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
} finally {
|
||||
@@ -30,26 +31,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
throw new Error(error.message || 'Login failed')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setUser(data.user)
|
||||
const authenticated = await loginRequest(username, password)
|
||||
setUser(authenticated)
|
||||
|
||||
// Redirect based on role
|
||||
if (data.user.role === 'supergod') {
|
||||
if (authenticated.role === 'supergod') {
|
||||
router.push('/(auth)/supergod')
|
||||
} else if (data.user.role === 'god') {
|
||||
} else if (authenticated.role === 'god') {
|
||||
router.push('/(auth)/builder')
|
||||
} else if (data.user.role === 'admin') {
|
||||
} else if (authenticated.role === 'admin') {
|
||||
router.push('/(auth)/admin')
|
||||
} else {
|
||||
router.push('/(auth)/dashboard')
|
||||
@@ -57,24 +47,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const register = async (username: string, email: string, password: string) => {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
throw new Error(error.message || 'Registration failed')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setUser(data.user)
|
||||
const registered = await registerRequest(username, email, password)
|
||||
setUser(registered)
|
||||
router.push('/(auth)/dashboard')
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
await logoutRequest()
|
||||
setUser(null)
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
66
frontends/nextjs/src/app/api/auth/login/route.ts
Normal file
66
frontends/nextjs/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readJson } from '@/lib/api/read-json'
|
||||
import { verifyCredentials } from '@/lib/db/credentials/verify-credentials'
|
||||
import { getUserByEmail } from '@/lib/db/users/get-user-by-email'
|
||||
import { getUserByUsername } from '@/lib/db/users/get-user-by-username'
|
||||
import { createSession } from '@/lib/db/sessions/create-session'
|
||||
import { DEFAULT_SESSION_TTL_MS } from '@/lib/auth/session-constants'
|
||||
import { setSessionCookie } from '@/lib/auth/set-session-cookie'
|
||||
|
||||
interface LoginPayload {
|
||||
identifier?: string
|
||||
username?: string
|
||||
email?: string
|
||||
password?: string
|
||||
tenantId?: string
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await readJson<LoginPayload>(request)
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 })
|
||||
}
|
||||
|
||||
const identifier = [body.identifier, body.username, body.email]
|
||||
.find((value) => typeof value === 'string' && value.trim().length > 0)
|
||||
?.trim()
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
const tenantId = typeof body.tenantId === 'string' && body.tenantId.trim()
|
||||
? body.tenantId.trim()
|
||||
: undefined
|
||||
|
||||
if (!identifier || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username/email and password are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const lookupOptions = tenantId ? { tenantId } : undefined
|
||||
let user = identifier.includes('@')
|
||||
? await getUserByEmail(identifier, lookupOptions)
|
||||
: await getUserByUsername(identifier, lookupOptions)
|
||||
|
||||
if (!user) {
|
||||
user = identifier.includes('@')
|
||||
? await getUserByUsername(identifier, lookupOptions)
|
||||
: await getUserByEmail(identifier, lookupOptions)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||
}
|
||||
|
||||
const isValid = await verifyCredentials(user.username, password)
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + DEFAULT_SESSION_TTL_MS
|
||||
const session = await createSession({ userId: user.id, expiresAt })
|
||||
|
||||
const response = NextResponse.json({ user })
|
||||
setSessionCookie(response, session.token, { maxAgeMs: DEFAULT_SESSION_TTL_MS })
|
||||
return response
|
||||
}
|
||||
18
frontends/nextjs/src/app/api/auth/logout/route.ts
Normal file
18
frontends/nextjs/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { deleteSessionByToken } from '@/lib/db/sessions/delete-session-by-token'
|
||||
import { clearSessionCookie } from '@/lib/auth/clear-session-cookie'
|
||||
import { AUTH_COOKIE_NAME } from '@/lib/auth/session-constants'
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = cookies()
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value
|
||||
|
||||
if (token) {
|
||||
await deleteSessionByToken(token)
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ success: true })
|
||||
clearSessionCookie(response)
|
||||
return response
|
||||
}
|
||||
82
frontends/nextjs/src/app/api/auth/register/route.ts
Normal file
82
frontends/nextjs/src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { readJson } from '@/lib/api/read-json'
|
||||
import { addUser } from '@/lib/db/users/add-user'
|
||||
import { getUserByEmail } from '@/lib/db/users/get-user-by-email'
|
||||
import { getUserByUsername } from '@/lib/db/users/get-user-by-username'
|
||||
import { hashPassword } from '@/lib/db/hash-password'
|
||||
import { setCredential } from '@/lib/db/credentials/set-credential'
|
||||
import { createSession } from '@/lib/db/sessions/create-session'
|
||||
import { DEFAULT_SESSION_TTL_MS } from '@/lib/auth/session-constants'
|
||||
import { setSessionCookie } from '@/lib/auth/set-session-cookie'
|
||||
import type { UserRole } from '@/lib/level-types'
|
||||
|
||||
interface RegisterPayload {
|
||||
username?: string
|
||||
email?: string
|
||||
password?: string
|
||||
tenantId?: string
|
||||
profilePicture?: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ROLE: UserRole = 'user'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await readJson<RegisterPayload>(request)
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 })
|
||||
}
|
||||
|
||||
const username = typeof body.username === 'string' ? body.username.trim() : ''
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : ''
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
const tenantId = typeof body.tenantId === 'string' && body.tenantId.trim()
|
||||
? body.tenantId.trim()
|
||||
: undefined
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username, email, and password are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const lookupOptions = tenantId ? { tenantId } : undefined
|
||||
const [existingByUsername, existingByEmail] = await Promise.all([
|
||||
getUserByUsername(username, lookupOptions),
|
||||
getUserByEmail(email, lookupOptions),
|
||||
])
|
||||
|
||||
if (existingByUsername || existingByEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: randomUUID(),
|
||||
username,
|
||||
email,
|
||||
role: DEFAULT_ROLE,
|
||||
profilePicture: body.profilePicture,
|
||||
bio: body.bio,
|
||||
createdAt: Date.now(),
|
||||
tenantId,
|
||||
isInstanceOwner: false,
|
||||
}
|
||||
|
||||
await addUser(user)
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
await setCredential(username, passwordHash)
|
||||
|
||||
const expiresAt = Date.now() + DEFAULT_SESSION_TTL_MS
|
||||
const session = await createSession({ userId: user.id, expiresAt })
|
||||
|
||||
const response = NextResponse.json({ user }, { status: 201 })
|
||||
setSessionCookie(response, session.token, { maxAgeMs: DEFAULT_SESSION_TTL_MS })
|
||||
return response
|
||||
}
|
||||
45
frontends/nextjs/src/app/api/auth/session/route.ts
Normal file
45
frontends/nextjs/src/app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { AUTH_COOKIE_NAME, DEFAULT_SESSION_TTL_MS } from '@/lib/auth/session-constants'
|
||||
import { clearSessionCookie } from '@/lib/auth/clear-session-cookie'
|
||||
import { setSessionCookie } from '@/lib/auth/set-session-cookie'
|
||||
import { getSessionByToken } from '@/lib/db/sessions/get-session-by-token'
|
||||
import { updateSession } from '@/lib/db/sessions/update-session'
|
||||
import { deleteSession } from '@/lib/db/sessions/delete-session'
|
||||
import { getUserById } from '@/lib/db/users/get-user-by-id'
|
||||
|
||||
export async function GET() {
|
||||
const cookieStore = cookies()
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ user: null })
|
||||
}
|
||||
|
||||
const session = await getSessionByToken(token)
|
||||
if (!session) {
|
||||
const response = NextResponse.json({ user: null })
|
||||
clearSessionCookie(response)
|
||||
return response
|
||||
}
|
||||
|
||||
const user = await getUserById(session.userId)
|
||||
if (!user) {
|
||||
await deleteSession(session.id)
|
||||
const response = NextResponse.json({ user: null })
|
||||
clearSessionCookie(response)
|
||||
return response
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const refreshedExpiry = now + DEFAULT_SESSION_TTL_MS
|
||||
|
||||
await updateSession(session.id, {
|
||||
lastActivity: now,
|
||||
expiresAt: refreshedExpiry,
|
||||
})
|
||||
|
||||
const response = NextResponse.json({ user })
|
||||
setSessionCookie(response, session.token, { maxAgeMs: DEFAULT_SESSION_TTL_MS })
|
||||
return response
|
||||
}
|
||||
@@ -2,54 +2,42 @@ import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createGitHubClient } from '@/lib/github/create-github-client'
|
||||
import { fetchWorkflowRunLogs } from '@/lib/github/fetch-workflow-run-logs'
|
||||
import { parseWorkflowRunLogsOptions } from '@/lib/github/parse-workflow-run-logs-options'
|
||||
import { resolveGitHubRepo } from '@/lib/github/resolve-github-repo'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { runId: string } }
|
||||
) {
|
||||
interface RouteParams {
|
||||
params: {
|
||||
runId: string
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = async (request: NextRequest, { params }: RouteParams) => {
|
||||
const runId = Number(params.runId)
|
||||
if (!Number.isFinite(runId) || runId <= 0) {
|
||||
return NextResponse.json({ error: 'Invalid run id' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const runId = Number(params.runId)
|
||||
if (!Number.isFinite(runId)) {
|
||||
return NextResponse.json({ error: 'Invalid run id' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { owner, repo } = resolveGitHubRepo(request.nextUrl.searchParams)
|
||||
const runName = request.nextUrl.searchParams.get('runName')?.trim() || `Run ${runId}`
|
||||
const includeLogsParam = request.nextUrl.searchParams.get('includeLogs')
|
||||
const includeLogs = includeLogsParam
|
||||
? !['false', '0', 'no'].includes(includeLogsParam.toLowerCase())
|
||||
: true
|
||||
const jobLimitParam = request.nextUrl.searchParams.get('jobLimit')
|
||||
let jobLimit = 20
|
||||
|
||||
if (jobLimitParam) {
|
||||
const parsed = Number(jobLimitParam)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
jobLimit = Math.max(1, Math.min(100, Math.floor(parsed)))
|
||||
}
|
||||
}
|
||||
|
||||
const { runName, includeLogs, jobLimit } = parseWorkflowRunLogsOptions(
|
||||
request.nextUrl.searchParams
|
||||
)
|
||||
const client = createGitHubClient()
|
||||
|
||||
const { jobs, logsText, truncated } = await fetchWorkflowRunLogs({
|
||||
client,
|
||||
owner,
|
||||
repo,
|
||||
runId,
|
||||
runId: Math.floor(runId),
|
||||
runName,
|
||||
jobLimit,
|
||||
includeLogs,
|
||||
jobLimit,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
owner,
|
||||
repo,
|
||||
runId,
|
||||
runName,
|
||||
jobs,
|
||||
logsText,
|
||||
truncated,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
const status = typeof error === 'object' && error && 'status' in error
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createGitHubClient } from '@/lib/github/create-github-client'
|
||||
import { resolveGitHubRepo } from '@/lib/github/resolve-github-repo'
|
||||
import { listWorkflowRuns } from '@/lib/github/list-workflow-runs'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export const GET = async (request: NextRequest) => {
|
||||
try {
|
||||
const { owner, repo } = resolveGitHubRepo(request.nextUrl.searchParams)
|
||||
const perPageParam = request.nextUrl.searchParams.get('perPage')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,9 @@ Simple groups of atoms working together:
|
||||
- DropdownMenu, RadioGroup, Popover
|
||||
- ToggleGroup, Breadcrumb
|
||||
|
||||
### Tabs
|
||||
`Tabs` manages selection state. Provide `defaultValue` for uncontrolled usage or `value`/`onValueChange` for controlled usage. `TabsContent` hides inactive panels while keeping them mounted.
|
||||
|
||||
### Organisms
|
||||
Complex, distinct UI sections:
|
||||
- Table, Form, Sheet
|
||||
|
||||
@@ -1,132 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, ReactNode, useState } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
|
||||
interface TabsProps {
|
||||
children: ReactNode
|
||||
defaultValue?: string
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
||||
({ children, defaultValue, value, onValueChange, ...props }, ref) => {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue ?? '')
|
||||
const _currentValue = value ?? internalValue
|
||||
|
||||
const _handleChange = (_newValue: string) => {
|
||||
if (!value) setInternalValue(_newValue)
|
||||
onValueChange?.(_newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box ref={ref} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }} {...props}>
|
||||
{/* Pass value down through children */}
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
Tabs.displayName = 'Tabs'
|
||||
|
||||
interface TabsListProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
|
||||
({ children, ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 2,
|
||||
p: 0.5,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
TabsList.displayName = 'TabsList'
|
||||
|
||||
interface TabsTriggerProps {
|
||||
children: ReactNode
|
||||
value: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TabsTrigger = forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
||||
({ children, value, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
component="button"
|
||||
role="tab"
|
||||
data-value={value}
|
||||
disabled={disabled}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
px: 2,
|
||||
py: 0.75,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
borderRadius: 1.5,
|
||||
border: 0,
|
||||
bgcolor: 'transparent',
|
||||
color: 'text.secondary',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': {
|
||||
color: 'text.primary',
|
||||
},
|
||||
'&[aria-selected="true"]': {
|
||||
bgcolor: 'background.paper',
|
||||
color: 'text.primary',
|
||||
boxShadow: 1,
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
TabsTrigger.displayName = 'TabsTrigger'
|
||||
|
||||
interface TabsContentProps {
|
||||
children: ReactNode
|
||||
value: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TabsContent = forwardRef<HTMLDivElement, TabsContentProps>(
|
||||
({ children, value, ...props }, ref) => {
|
||||
return (
|
||||
<Box ref={ref} role="tabpanel" data-value={value} sx={{ flex: 1, outline: 'none' }} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
TabsContent.displayName = 'TabsContent'
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs } from './tabs/Tabs'
|
||||
export type { TabsProps } from './tabs/Tabs'
|
||||
export { TabsList } from './tabs/TabsList'
|
||||
export type { TabsListProps } from './tabs/TabsList'
|
||||
export { TabsTrigger } from './tabs/TabsTrigger'
|
||||
export type { TabsTriggerProps } from './tabs/TabsTrigger'
|
||||
export { TabsContent } from './tabs/TabsContent'
|
||||
export type { TabsContentProps } from './tabs/TabsContent'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui'
|
||||
|
||||
describe('Tabs', () => {
|
||||
it('switches active tab and hides inactive panels', () => {
|
||||
render(
|
||||
<Tabs defaultValue="alpha">
|
||||
<TabsList>
|
||||
<TabsTrigger value="alpha">Alpha</TabsTrigger>
|
||||
<TabsTrigger value="beta">Beta</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="alpha">Alpha content</TabsContent>
|
||||
<TabsContent value="beta">Beta content</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
const alphaPanel = screen.getByText('Alpha content').closest('[role="tabpanel"]')
|
||||
const betaPanel = screen.getByText('Beta content').closest('[role="tabpanel"]')
|
||||
|
||||
expect(alphaPanel?.hasAttribute('hidden')).toBe(false)
|
||||
expect(betaPanel?.hasAttribute('hidden')).toBe(true)
|
||||
|
||||
const betaTab = screen.getByRole('tab', { name: 'Beta' })
|
||||
fireEvent.click(betaTab)
|
||||
|
||||
expect(alphaPanel?.hasAttribute('hidden')).toBe(true)
|
||||
expect(betaPanel?.hasAttribute('hidden')).toBe(false)
|
||||
|
||||
const alphaTab = screen.getByRole('tab', { name: 'Alpha' })
|
||||
expect(alphaTab.getAttribute('aria-selected')).toBe('false')
|
||||
expect(betaTab.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
})
|
||||
43
frontends/nextjs/src/components/ui/molecules/tabs/Tabs.tsx
Normal file
43
frontends/nextjs/src/components/ui/molecules/tabs/Tabs.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useId, useState } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import type { BoxProps } from '@mui/material'
|
||||
import { TabsContext } from './tabs-context'
|
||||
|
||||
export interface TabsProps extends BoxProps {
|
||||
defaultValue?: string
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
||||
({ children, defaultValue, value, onValueChange, sx, ...props }, ref) => {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue ?? '')
|
||||
const currentValue = value ?? internalValue
|
||||
const idPrefix = useId()
|
||||
|
||||
const handleValueChange = (nextValue: string) => {
|
||||
if (value === undefined) {
|
||||
setInternalValue(nextValue)
|
||||
}
|
||||
onValueChange?.(nextValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ value: currentValue, setValue: handleValueChange, idPrefix }}>
|
||||
<Box
|
||||
ref={ref}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, ...sx }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Tabs.displayName = 'Tabs'
|
||||
|
||||
export { Tabs }
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useContext } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import type { BoxProps } from '@mui/material'
|
||||
import { TabsContext } from './tabs-context'
|
||||
|
||||
export interface TabsContentProps extends BoxProps {
|
||||
value: string
|
||||
}
|
||||
|
||||
const TabsContent = forwardRef<HTMLDivElement, TabsContentProps>(
|
||||
({ children, value, sx, ...props }, ref) => {
|
||||
const context = useContext(TabsContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('TabsContent must be used within Tabs')
|
||||
}
|
||||
|
||||
const isActive = context.value === value
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
role="tabpanel"
|
||||
id={`${context.idPrefix}-panel-${value}`}
|
||||
aria-labelledby={`${context.idPrefix}-tab-${value}`}
|
||||
hidden={!isActive}
|
||||
sx={{
|
||||
flex: 1,
|
||||
outline: 'none',
|
||||
display: isActive ? 'block' : 'none',
|
||||
...sx,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TabsContent.displayName = 'TabsContent'
|
||||
|
||||
export { TabsContent }
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useContext } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import type { BoxProps } from '@mui/material'
|
||||
import { TabsContext } from './tabs-context'
|
||||
|
||||
export interface TabsListProps extends BoxProps {}
|
||||
|
||||
const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
|
||||
({ children, sx, ...props }, ref) => {
|
||||
const context = useContext(TabsContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('TabsList must be used within Tabs')
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 2,
|
||||
p: 0.5,
|
||||
...sx,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TabsList.displayName = 'TabsList'
|
||||
|
||||
export { TabsList }
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef, useContext } from 'react'
|
||||
import type { MouseEvent } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import type { BoxProps } from '@mui/material'
|
||||
import { TabsContext } from './tabs-context'
|
||||
|
||||
export interface TabsTriggerProps extends BoxProps<'button'> {
|
||||
value: string
|
||||
}
|
||||
|
||||
const TabsTrigger = forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
||||
({ children, value, onClick, disabled, sx, ...props }, ref) => {
|
||||
const context = useContext(TabsContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('TabsTrigger must be used within Tabs')
|
||||
}
|
||||
|
||||
const isSelected = context.value === value
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return
|
||||
onClick?.(event)
|
||||
if (!event.defaultPrevented) {
|
||||
context.setValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
component="button"
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${context.idPrefix}-tab-${value}`}
|
||||
aria-controls={`${context.idPrefix}-panel-${value}`}
|
||||
aria-selected={isSelected}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
disabled={disabled}
|
||||
data-state={isSelected ? 'active' : 'inactive'}
|
||||
data-value={value}
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
px: 2,
|
||||
py: 0.75,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
borderRadius: 1.5,
|
||||
border: 0,
|
||||
bgcolor: 'transparent',
|
||||
color: 'text.secondary',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': {
|
||||
color: 'text.primary',
|
||||
},
|
||||
'&[aria-selected="true"]': {
|
||||
bgcolor: 'background.paper',
|
||||
color: 'text.primary',
|
||||
boxShadow: 1,
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TabsTrigger.displayName = 'TabsTrigger'
|
||||
|
||||
export { TabsTrigger }
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export interface TabsContextValue {
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
idPrefix: string
|
||||
}
|
||||
|
||||
export const TabsContext = createContext<TabsContextValue | null>(null)
|
||||
133
frontends/nextjs/src/hooks/auth/auth-store.ts
Normal file
133
frontends/nextjs/src/hooks/auth/auth-store.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { User } from '@/lib/level-types'
|
||||
import { fetchSession } from '@/lib/auth/api/fetch-session'
|
||||
import { login as loginRequest } from '@/lib/auth/api/login'
|
||||
import { logout as logoutRequest } from '@/lib/auth/api/logout'
|
||||
import type { AuthState, AuthUser } from './auth-types'
|
||||
|
||||
const roleLevels: Record<string, number> = {
|
||||
public: 1,
|
||||
user: 2,
|
||||
admin: 3,
|
||||
god: 4,
|
||||
supergod: 5,
|
||||
}
|
||||
|
||||
export class AuthStore {
|
||||
private state: AuthState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
private listeners = new Set<() => void>()
|
||||
private sessionCheckPromise: Promise<void> | null = null
|
||||
|
||||
getState(): AuthState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
async ensureSessionChecked(): Promise<void> {
|
||||
if (!this.sessionCheckPromise) {
|
||||
this.sessionCheckPromise = this.refresh().finally(() => {
|
||||
this.sessionCheckPromise = null
|
||||
})
|
||||
}
|
||||
return this.sessionCheckPromise
|
||||
}
|
||||
|
||||
async login(identifier: string, password: string): Promise<void> {
|
||||
this.setState({
|
||||
...this.state,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
try {
|
||||
const user = await loginRequest(identifier, password)
|
||||
this.setState({
|
||||
user: this.mapUserToAuthUser(user),
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (error) {
|
||||
this.setState({
|
||||
...this.state,
|
||||
isLoading: false,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
this.setState({
|
||||
...this.state,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
try {
|
||||
await logoutRequest()
|
||||
this.setState({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (error) {
|
||||
this.setState({
|
||||
...this.state,
|
||||
isLoading: false,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
this.setState({
|
||||
...this.state,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
try {
|
||||
const sessionUser = await fetchSession()
|
||||
this.setState({
|
||||
user: sessionUser ? this.mapUserToAuthUser(sessionUser) : null,
|
||||
isAuthenticated: Boolean(sessionUser),
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh auth session:', error)
|
||||
this.setState({
|
||||
...this.state,
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private mapUserToAuthUser(user: User): AuthUser {
|
||||
const level = roleLevels[user.role]
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
name: user.username,
|
||||
role: user.role,
|
||||
level,
|
||||
tenantId: user.tenantId,
|
||||
profilePicture: user.profilePicture,
|
||||
bio: user.bio,
|
||||
isInstanceOwner: user.isInstanceOwner,
|
||||
}
|
||||
}
|
||||
|
||||
private setState(next: AuthState): void {
|
||||
this.state = next
|
||||
this.listeners.forEach((listener) => listener())
|
||||
}
|
||||
}
|
||||
|
||||
export const authStore = new AuthStore()
|
||||
26
frontends/nextjs/src/hooks/auth/auth-types.ts
Normal file
26
frontends/nextjs/src/hooks/auth/auth-types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { UserRole } from '@/lib/level-types'
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
username?: string
|
||||
name?: string
|
||||
role?: UserRole
|
||||
level?: number
|
||||
tenantId?: string
|
||||
profilePicture?: string
|
||||
bio?: string
|
||||
isInstanceOwner?: boolean
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: AuthUser | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export interface UseAuthReturn extends AuthState {
|
||||
login: (identifier: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export { useGitHubFetcher } from './useGitHubFetcher'
|
||||
export { useKV } from './useKV'
|
||||
export { useIsMobile } from './use-mobile'
|
||||
|
||||
export type { AuthUser, AuthState, UseAuthReturn } from './useAuth'
|
||||
export type { AuthUser, AuthState, UseAuthReturn } from './auth/auth-types'
|
||||
export type { EditorFile } from './useCodeEditor'
|
||||
export type { FileNode } from './useFileTree'
|
||||
export type { WorkflowRun } from './useGitHubFetcher'
|
||||
|
||||
@@ -1,37 +1,68 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
const mockFetchSession = vi.fn()
|
||||
const mockLogin = vi.fn()
|
||||
const mockLogout = vi.fn()
|
||||
|
||||
vi.mock('@/lib/auth/api/fetch-session', () => ({
|
||||
fetchSession: mockFetchSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api/login', () => ({
|
||||
login: mockLogin,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api/logout', () => ({
|
||||
logout: mockLogout,
|
||||
}))
|
||||
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
const createUser = (overrides?: Partial<User>): User => ({
|
||||
id: 'user_1',
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
role: 'user',
|
||||
createdAt: 1000,
|
||||
tenantId: undefined,
|
||||
profilePicture: undefined,
|
||||
bio: undefined,
|
||||
isInstanceOwner: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const waitForIdle = async (result: { current: { isLoading: boolean } }) => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
describe('useAuth', () => {
|
||||
const completeAuthAction = async (action: () => Promise<void>) => {
|
||||
await act(async () => {
|
||||
const promise = action()
|
||||
vi.advanceTimersByTime(100)
|
||||
await promise
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
mockFetchSession.mockReset()
|
||||
mockLogin.mockReset()
|
||||
mockLogout.mockReset()
|
||||
mockFetchSession.mockResolvedValue(null)
|
||||
mockLogout.mockResolvedValue(undefined)
|
||||
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
await completeAuthAction(() => result.current.logout())
|
||||
await waitForIdle(result)
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
await waitForIdle(result)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('should start unauthenticated', () => {
|
||||
it('should start unauthenticated after session check', async () => {
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
|
||||
await waitForIdle(result)
|
||||
|
||||
expect(result.current.user).toBeNull()
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
|
||||
unmount()
|
||||
})
|
||||
@@ -42,15 +73,25 @@ describe('useAuth', () => {
|
||||
])('should authenticate $email', async ({ email, expectedName }) => {
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
|
||||
await completeAuthAction(() => result.current.login(email, 'password'))
|
||||
mockLogin.mockResolvedValue(createUser({
|
||||
id: 'user_1',
|
||||
username: expectedName,
|
||||
email,
|
||||
}))
|
||||
|
||||
await waitForIdle(result)
|
||||
await act(async () => {
|
||||
await result.current.login(email, 'password')
|
||||
})
|
||||
|
||||
expect(result.current.user).toMatchObject({
|
||||
id: '1',
|
||||
id: 'user_1',
|
||||
email,
|
||||
name: expectedName,
|
||||
username: expectedName,
|
||||
level: 2,
|
||||
})
|
||||
expect(result.current.isAuthenticated).toBe(true)
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
|
||||
unmount()
|
||||
})
|
||||
@@ -58,12 +99,19 @@ describe('useAuth', () => {
|
||||
it('should clear user on logout', async () => {
|
||||
const { result, unmount } = renderHook(() => useAuth())
|
||||
|
||||
await completeAuthAction(() => result.current.login('logout@example.com', 'password'))
|
||||
await completeAuthAction(() => result.current.logout())
|
||||
mockLogin.mockResolvedValue(createUser())
|
||||
|
||||
await waitForIdle(result)
|
||||
await act(async () => {
|
||||
await result.current.login('alice@example.com', 'password')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout()
|
||||
})
|
||||
|
||||
expect(result.current.user).toBeNull()
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
|
||||
unmount()
|
||||
})
|
||||
@@ -72,12 +120,21 @@ describe('useAuth', () => {
|
||||
const first = renderHook(() => useAuth())
|
||||
const second = renderHook(() => useAuth())
|
||||
|
||||
await completeAuthAction(() => first.result.current.login('sync@example.com', 'password'))
|
||||
mockLogin.mockResolvedValue(createUser({ email: 'sync@example.com', username: 'sync' }))
|
||||
|
||||
await waitForIdle(first.result)
|
||||
await waitForIdle(second.result)
|
||||
|
||||
await act(async () => {
|
||||
await first.result.current.login('sync@example.com', 'password')
|
||||
})
|
||||
|
||||
expect(second.result.current.isAuthenticated).toBe(true)
|
||||
expect(second.result.current.user?.email).toBe('sync@example.com')
|
||||
|
||||
await completeAuthAction(() => second.result.current.logout())
|
||||
await act(async () => {
|
||||
await second.result.current.logout()
|
||||
})
|
||||
|
||||
expect(first.result.current.isAuthenticated).toBe(false)
|
||||
expect(first.result.current.user).toBeNull()
|
||||
|
||||
@@ -1,85 +1,30 @@
|
||||
/**
|
||||
* useAuth hook - simple authentication state management
|
||||
* useAuth hook - authentication state management
|
||||
* Provides user authentication state and methods
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name?: string
|
||||
role?: 'user' | 'admin' | 'god' | 'supergod'
|
||||
level?: number
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: AuthUser | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export interface UseAuthReturn extends AuthState {
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
// Simple in-memory auth state for now
|
||||
// TODO: Implement proper auth with backend/Prisma
|
||||
let authState: AuthState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function notifyListeners() {
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
import { authStore } from './auth/auth-store'
|
||||
import type { AuthState, UseAuthReturn } from './auth/auth-types'
|
||||
|
||||
export function useAuth(): UseAuthReturn {
|
||||
const [state, setState] = useState<AuthState>(authState)
|
||||
const [state, setState] = useState<AuthState>(authStore.getState())
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => setState({ ...authState })
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
const unsubscribe = authStore.subscribe(() => setState({ ...authStore.getState() }))
|
||||
void authStore.ensureSessionChecked()
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (email: string, _password: string) => {
|
||||
authState = { ...authState, isLoading: true }
|
||||
notifyListeners()
|
||||
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
authState = {
|
||||
user: { id: '1', email, name: email.split('@')[0] },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}
|
||||
notifyListeners()
|
||||
const login = useCallback(async (identifier: string, password: string) => {
|
||||
await authStore.login(identifier, password)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
authState = { ...authState, isLoading: true }
|
||||
notifyListeners()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
authState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
}
|
||||
notifyListeners()
|
||||
await authStore.logout()
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
// No-op for now, would refresh token/session
|
||||
await authStore.refresh()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
|
||||
7
frontends/nextjs/src/lib/api/read-json.ts
Normal file
7
frontends/nextjs/src/lib/api/read-json.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export async function readJson<T>(request: Request): Promise<T | null> {
|
||||
try {
|
||||
return (await request.json()) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
13
frontends/nextjs/src/lib/auth/api/fetch-session.ts
Normal file
13
frontends/nextjs/src/lib/auth/api/fetch-session.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
export async function fetchSession(): Promise<User | null> {
|
||||
try {
|
||||
const response = await fetch('/api/auth/session', { cache: 'no-store' })
|
||||
if (!response.ok) return null
|
||||
const payload = (await response.json()) as { user?: User | null } | null
|
||||
return payload?.user ?? null
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch session:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
4
frontends/nextjs/src/lib/auth/api/index.ts
Normal file
4
frontends/nextjs/src/lib/auth/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { fetchSession } from './fetch-session'
|
||||
export { login } from './login'
|
||||
export { logout } from './logout'
|
||||
export { register } from './register'
|
||||
22
frontends/nextjs/src/lib/auth/api/login.ts
Normal file
22
frontends/nextjs/src/lib/auth/api/login.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
export async function login(identifier: string, password: string, options?: { tenantId?: string }): Promise<User> {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
identifier,
|
||||
password,
|
||||
tenantId: options?.tenantId,
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as { user?: User; error?: string } | null
|
||||
|
||||
if (!response.ok || !payload?.user) {
|
||||
const message = payload?.error || 'Login failed'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return payload.user
|
||||
}
|
||||
8
frontends/nextjs/src/lib/auth/api/logout.ts
Normal file
8
frontends/nextjs/src/lib/auth/api/logout.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export async function logout(): Promise<void> {
|
||||
const response = await fetch('/api/auth/logout', { method: 'POST' })
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
const message = payload?.error || 'Logout failed'
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
30
frontends/nextjs/src/lib/auth/api/register.ts
Normal file
30
frontends/nextjs/src/lib/auth/api/register.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { User } from '@/lib/level-types'
|
||||
|
||||
export async function register(
|
||||
username: string,
|
||||
email: string,
|
||||
password: string,
|
||||
options?: { tenantId?: string; profilePicture?: string; bio?: string }
|
||||
): Promise<User> {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
tenantId: options?.tenantId,
|
||||
profilePicture: options?.profilePicture,
|
||||
bio: options?.bio,
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as { user?: User; error?: string } | null
|
||||
|
||||
if (!response.ok || !payload?.user) {
|
||||
const message = payload?.error || 'Registration failed'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return payload.user
|
||||
}
|
||||
12
frontends/nextjs/src/lib/auth/clear-session-cookie.ts
Normal file
12
frontends/nextjs/src/lib/auth/clear-session-cookie.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { AUTH_COOKIE_NAME } from './session-constants'
|
||||
|
||||
export function clearSessionCookie(response: NextResponse): void {
|
||||
response.cookies.set(AUTH_COOKIE_NAME, '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
})
|
||||
}
|
||||
2
frontends/nextjs/src/lib/auth/session-constants.ts
Normal file
2
frontends/nextjs/src/lib/auth/session-constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const AUTH_COOKIE_NAME = 'mb_session'
|
||||
export const DEFAULT_SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7
|
||||
17
frontends/nextjs/src/lib/auth/set-session-cookie.ts
Normal file
17
frontends/nextjs/src/lib/auth/set-session-cookie.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NextResponse } from 'next/server'
|
||||
import { AUTH_COOKIE_NAME, DEFAULT_SESSION_TTL_MS } from './session-constants'
|
||||
|
||||
export function setSessionCookie(
|
||||
response: NextResponse,
|
||||
token: string,
|
||||
options?: { maxAgeMs?: number }
|
||||
): void {
|
||||
const maxAgeMs = options?.maxAgeMs ?? DEFAULT_SESSION_TTL_MS
|
||||
response.cookies.set(AUTH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: Math.floor(maxAgeMs / 1000),
|
||||
})
|
||||
}
|
||||
72
frontends/nextjs/src/lib/db/auth/authenticate-user.ts
Normal file
72
frontends/nextjs/src/lib/db/auth/authenticate-user.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { getAdapter } from '../dbal-client'
|
||||
import { verifyPassword } from '../verify-password'
|
||||
import type { User } from '../../types/level-types'
|
||||
|
||||
export interface AuthenticateResult {
|
||||
success: boolean
|
||||
user: User | null
|
||||
error?: 'invalid_credentials' | 'user_not_found' | 'account_locked'
|
||||
requiresPasswordChange?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user by username and password.
|
||||
* Returns user data on success, error code on failure.
|
||||
* Uses DBAL adapter - never accesses Prisma directly.
|
||||
*/
|
||||
export const authenticateUser = async (
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<AuthenticateResult> => {
|
||||
const adapter = getAdapter()
|
||||
|
||||
// Look up credentials
|
||||
const credResult = await adapter.list('Credential', {
|
||||
filter: { username },
|
||||
})
|
||||
|
||||
if (credResult.data.length === 0) {
|
||||
return { success: false, user: null, error: 'invalid_credentials' }
|
||||
}
|
||||
|
||||
const credential = credResult.data[0] as { username: string; passwordHash: string }
|
||||
const passwordValid = await verifyPassword(password, credential.passwordHash)
|
||||
|
||||
if (!passwordValid) {
|
||||
return { success: false, user: null, error: 'invalid_credentials' }
|
||||
}
|
||||
|
||||
// Fetch user data
|
||||
const userResult = await adapter.list('User', {
|
||||
filter: { username },
|
||||
})
|
||||
|
||||
if (userResult.data.length === 0) {
|
||||
return { success: false, user: null, error: 'user_not_found' }
|
||||
}
|
||||
|
||||
const userData = userResult.data[0] as Record<string, unknown>
|
||||
|
||||
const user: User = {
|
||||
id: String(userData.id),
|
||||
username: String(userData.username),
|
||||
email: String(userData.email),
|
||||
role: userData.role as User['role'],
|
||||
profilePicture: userData.profilePicture ? String(userData.profilePicture) : undefined,
|
||||
bio: userData.bio ? String(userData.bio) : undefined,
|
||||
createdAt: Number(userData.createdAt),
|
||||
tenantId: userData.tenantId ? String(userData.tenantId) : undefined,
|
||||
isInstanceOwner: Boolean(userData.isInstanceOwner),
|
||||
}
|
||||
|
||||
// Check first login flag
|
||||
const firstLoginResult = await adapter.findFirst('GodCredentialExpiry', {
|
||||
where: { settingKey: `first_login_${username}` },
|
||||
})
|
||||
|
||||
const requiresPasswordChange = firstLoginResult
|
||||
? (firstLoginResult as { value: string }).value === 'true'
|
||||
: false
|
||||
|
||||
return { success: true, user, requiresPasswordChange }
|
||||
}
|
||||
32
frontends/nextjs/src/lib/db/auth/get-user-by-email.ts
Normal file
32
frontends/nextjs/src/lib/db/auth/get-user-by-email.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getAdapter } from '../dbal-client'
|
||||
import type { User } from '../../types/level-types'
|
||||
|
||||
/**
|
||||
* Get user by email from DBAL.
|
||||
* Single-responsibility lambda for email lookup.
|
||||
*/
|
||||
export const getUserByEmail = async (email: string): Promise<User | null> => {
|
||||
const adapter = getAdapter()
|
||||
|
||||
const result = await adapter.list('User', {
|
||||
filter: { email },
|
||||
})
|
||||
|
||||
if (result.data.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const userData = result.data[0] as Record<string, unknown>
|
||||
|
||||
return {
|
||||
id: String(userData.id),
|
||||
username: String(userData.username),
|
||||
email: String(userData.email),
|
||||
role: userData.role as User['role'],
|
||||
profilePicture: userData.profilePicture ? String(userData.profilePicture) : undefined,
|
||||
bio: userData.bio ? String(userData.bio) : undefined,
|
||||
createdAt: Number(userData.createdAt),
|
||||
tenantId: userData.tenantId ? String(userData.tenantId) : undefined,
|
||||
isInstanceOwner: Boolean(userData.isInstanceOwner),
|
||||
}
|
||||
}
|
||||
38
frontends/nextjs/src/lib/db/auth/get-user-by-username.ts
Normal file
38
frontends/nextjs/src/lib/db/auth/get-user-by-username.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getAdapter } from '../dbal-client'
|
||||
import type { User } from '../../types/level-types'
|
||||
|
||||
/**
|
||||
* Get user by username from DBAL.
|
||||
* Single-responsibility lambda for username lookup.
|
||||
*/
|
||||
export const getUserByUsername = async (
|
||||
username: string,
|
||||
options?: { tenantId?: string }
|
||||
): Promise<User | null> => {
|
||||
const adapter = getAdapter()
|
||||
|
||||
const record = await adapter.findFirst('User', {
|
||||
where: {
|
||||
username,
|
||||
...(options?.tenantId ? { tenantId: options.tenantId } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const userData = record as Record<string, unknown>
|
||||
|
||||
return {
|
||||
id: String(userData.id),
|
||||
username: String(userData.username),
|
||||
email: String(userData.email),
|
||||
role: userData.role as User['role'],
|
||||
profilePicture: userData.profilePicture ? String(userData.profilePicture) : undefined,
|
||||
bio: userData.bio ? String(userData.bio) : undefined,
|
||||
createdAt: Number(userData.createdAt),
|
||||
tenantId: userData.tenantId ? String(userData.tenantId) : undefined,
|
||||
isInstanceOwner: Boolean(userData.isInstanceOwner),
|
||||
}
|
||||
}
|
||||
4
frontends/nextjs/src/lib/db/auth/index.ts
Normal file
4
frontends/nextjs/src/lib/db/auth/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { authenticateUser } from './authenticate-user'
|
||||
export type { AuthenticateResult } from './authenticate-user'
|
||||
export { getUserByUsername } from './get-user-by-username'
|
||||
export { getUserByEmail } from './get-user-by-email'
|
||||
@@ -1,9 +1,11 @@
|
||||
import { getModel } from './get-model'
|
||||
import { getPrimaryKeyField } from './get-primary-key-field'
|
||||
|
||||
export async function deleteEntity(entity: string, id: string): Promise<boolean> {
|
||||
const model = getModel(entity)
|
||||
const primaryKeyField = getPrimaryKeyField(entity)
|
||||
try {
|
||||
await model.delete({ where: { id } })
|
||||
await model.delete({ where: { [primaryKeyField]: id } })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const PRIMARY_KEY_FIELDS: Record<string, string> = {
|
||||
Credential: 'username',
|
||||
InstalledPackage: 'packageId',
|
||||
PackageData: 'packageId',
|
||||
PasswordResetToken: 'username',
|
||||
SystemConfig: 'key',
|
||||
}
|
||||
|
||||
export function getPrimaryKeyField(entity: string): string {
|
||||
return PRIMARY_KEY_FIELDS[entity] ?? 'id'
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockGetModel = vi.fn()
|
||||
|
||||
vi.mock('./get-model', () => ({
|
||||
getModel: mockGetModel,
|
||||
}))
|
||||
|
||||
import { getPrimaryKeyField } from './get-primary-key-field'
|
||||
import { readEntity } from './read-entity'
|
||||
import { updateEntity } from './update-entity'
|
||||
import { deleteEntity } from './delete-entity'
|
||||
|
||||
describe('getPrimaryKeyField', () => {
|
||||
it.each([
|
||||
['Credential', 'username'],
|
||||
['InstalledPackage', 'packageId'],
|
||||
['PackageData', 'packageId'],
|
||||
['PasswordResetToken', 'username'],
|
||||
['SystemConfig', 'key'],
|
||||
])('maps %s to %s', (entity, expected) => {
|
||||
expect(getPrimaryKeyField(entity)).toBe(expected)
|
||||
})
|
||||
|
||||
it('defaults to id for unknown entities', () => {
|
||||
expect(getPrimaryKeyField('User')).toBe('id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dbal-client primary key usage', () => {
|
||||
const model = {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
model.findUnique.mockReset()
|
||||
model.update.mockReset()
|
||||
model.delete.mockReset()
|
||||
mockGetModel.mockReset()
|
||||
mockGetModel.mockReturnValue(model)
|
||||
})
|
||||
|
||||
it('uses mapped key for readEntity', async () => {
|
||||
model.findUnique.mockResolvedValue(null)
|
||||
|
||||
await readEntity('InstalledPackage', 'forum-classic')
|
||||
|
||||
expect(model.findUnique).toHaveBeenCalledWith({
|
||||
where: { packageId: 'forum-classic' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses mapped key for updateEntity', async () => {
|
||||
model.update.mockResolvedValue({ username: 'admin' })
|
||||
|
||||
await updateEntity('Credential', 'admin', { passwordHash: 'hash' })
|
||||
|
||||
expect(model.update).toHaveBeenCalledWith({
|
||||
where: { username: 'admin' },
|
||||
data: { passwordHash: 'hash' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses mapped key for deleteEntity', async () => {
|
||||
model.delete.mockResolvedValue({ key: 'theme' })
|
||||
|
||||
await deleteEntity('SystemConfig', 'theme')
|
||||
|
||||
expect(model.delete).toHaveBeenCalledWith({
|
||||
where: { key: 'theme' },
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to id for standard entities', async () => {
|
||||
model.findUnique.mockResolvedValue(null)
|
||||
|
||||
await readEntity('User', 'user_123')
|
||||
|
||||
expect(model.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'user_123' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getModel } from './get-model'
|
||||
import { getPrimaryKeyField } from './get-primary-key-field'
|
||||
|
||||
export async function readEntity(entity: string, id: string): Promise<unknown | null> {
|
||||
const model = getModel(entity)
|
||||
return model.findUnique({ where: { id } })
|
||||
const primaryKeyField = getPrimaryKeyField(entity)
|
||||
return model.findUnique({ where: { [primaryKeyField]: id } })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getModel } from './get-model'
|
||||
import { getPrimaryKeyField } from './get-primary-key-field'
|
||||
|
||||
export async function updateEntity(
|
||||
entity: string,
|
||||
@@ -6,6 +7,7 @@ export async function updateEntity(
|
||||
data: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const model = getModel(entity)
|
||||
const primaryKeyField = getPrimaryKeyField(entity)
|
||||
// Filter out undefined values
|
||||
const cleanData: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
@@ -13,5 +15,5 @@ export async function updateEntity(
|
||||
cleanData[key] = value
|
||||
}
|
||||
}
|
||||
return model.update({ where: { id }, data: cleanData })
|
||||
return model.update({ where: { [primaryKeyField]: id }, data: cleanData })
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ export { verifyPassword } from './verify-password'
|
||||
export { initializeDatabase } from './initialize-database'
|
||||
|
||||
// Domain re-exports
|
||||
export * from './auth'
|
||||
export * from './users'
|
||||
export * from './credentials'
|
||||
export * from './sessions'
|
||||
export * from './workflows'
|
||||
export * from './lua-scripts'
|
||||
export * from './pages'
|
||||
@@ -35,8 +37,10 @@ export * from './database-admin'
|
||||
import { initializeDatabase } from './initialize-database'
|
||||
import { hashPassword } from './hash-password'
|
||||
import { verifyPassword } from './verify-password'
|
||||
import * as auth from './auth'
|
||||
import * as users from './users'
|
||||
import * as credentials from './credentials'
|
||||
import * as sessions from './sessions'
|
||||
import * as workflows from './workflows'
|
||||
import * as luaScripts from './lua-scripts'
|
||||
import * as pages from './pages'
|
||||
@@ -64,6 +68,11 @@ export class Database {
|
||||
static hashPassword = hashPassword
|
||||
static verifyPassword = verifyPassword
|
||||
|
||||
// Auth
|
||||
static authenticateUser = auth.authenticateUser
|
||||
static getUserByUsername = auth.getUserByUsername
|
||||
static getUserByEmail = auth.getUserByEmail
|
||||
|
||||
// Users
|
||||
static getUsers = users.getUsers
|
||||
static getUserById = users.getUserById
|
||||
@@ -83,6 +92,15 @@ export class Database {
|
||||
static getPasswordResetTokens = credentials.getPasswordResetTokens
|
||||
static setPasswordResetToken = credentials.setPasswordResetToken
|
||||
static deletePasswordResetToken = credentials.deletePasswordResetToken
|
||||
|
||||
// Sessions
|
||||
static createSession = sessions.createSession
|
||||
static getSessionById = sessions.getSessionById
|
||||
static getSessionByToken = sessions.getSessionByToken
|
||||
static updateSession = sessions.updateSession
|
||||
static deleteSession = sessions.deleteSession
|
||||
static deleteSessionByToken = sessions.deleteSessionByToken
|
||||
static listSessions = sessions.listSessions
|
||||
|
||||
// Workflows
|
||||
static getWorkflows = workflows.getWorkflows
|
||||
|
||||
52
frontends/nextjs/src/lib/db/sessions/create-session.test.ts
Normal file
52
frontends/nextjs/src/lib/db/sessions/create-session.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockCreate = vi.fn()
|
||||
const mockAdapter = { create: mockCreate }
|
||||
|
||||
vi.mock('../dbal-client', () => ({
|
||||
getAdapter: () => mockAdapter,
|
||||
}))
|
||||
|
||||
import { createSession } from './create-session'
|
||||
|
||||
describe('createSession', () => {
|
||||
beforeEach(() => {
|
||||
mockCreate.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('creates a session record with explicit values', async () => {
|
||||
const now = 1700000000000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
|
||||
const result = await createSession({
|
||||
id: 'session_1',
|
||||
token: 'token_1',
|
||||
userId: 'user_1',
|
||||
expiresAt: 1700000100000,
|
||||
createdAt: 1699990000000,
|
||||
lastActivity: 1699990001000,
|
||||
})
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith('Session', {
|
||||
id: 'session_1',
|
||||
userId: 'user_1',
|
||||
token: 'token_1',
|
||||
expiresAt: BigInt(1700000100000),
|
||||
createdAt: BigInt(1699990000000),
|
||||
lastActivity: BigInt(1699990001000),
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'session_1',
|
||||
userId: 'user_1',
|
||||
token: 'token_1',
|
||||
expiresAt: 1700000100000,
|
||||
createdAt: 1699990000000,
|
||||
lastActivity: 1699990001000,
|
||||
})
|
||||
})
|
||||
})
|
||||
31
frontends/nextjs/src/lib/db/sessions/create-session.ts
Normal file
31
frontends/nextjs/src/lib/db/sessions/create-session.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { randomBytes, randomUUID } from 'crypto'
|
||||
import { getAdapter } from '../dbal-client'
|
||||
import type { CreateSessionInput, Session } from './types'
|
||||
|
||||
const TOKEN_BYTES = 32
|
||||
|
||||
export async function createSession(input: CreateSessionInput): Promise<Session> {
|
||||
const adapter = getAdapter()
|
||||
const createdAt = input.createdAt ?? Date.now()
|
||||
const lastActivity = input.lastActivity ?? createdAt
|
||||
const sessionId = input.id ?? randomUUID()
|
||||
const token = input.token ?? randomBytes(TOKEN_BYTES).toString('hex')
|
||||
|
||||
await adapter.create('Session', {
|
||||
id: sessionId,
|
||||
userId: input.userId,
|
||||
token,
|
||||
expiresAt: BigInt(input.expiresAt),
|
||||
createdAt: BigInt(createdAt),
|
||||
lastActivity: BigInt(lastActivity),
|
||||
})
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
userId: input.userId,
|
||||
token,
|
||||
expiresAt: input.expiresAt,
|
||||
createdAt,
|
||||
lastActivity,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockList = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
const mockAdapter = { list: mockList, delete: mockDelete }
|
||||
|
||||
vi.mock('../dbal-client', () => ({
|
||||
getAdapter: () => mockAdapter,
|
||||
}))
|
||||
|
||||
import { deleteSessionByToken } from './delete-session-by-token'
|
||||
|
||||
describe('deleteSessionByToken', () => {
|
||||
beforeEach(() => {
|
||||
mockList.mockReset()
|
||||
mockDelete.mockReset()
|
||||
})
|
||||
|
||||
it('returns false when no session exists', async () => {
|
||||
mockList.mockResolvedValue({ data: [] })
|
||||
|
||||
const result = await deleteSessionByToken('missing')
|
||||
|
||||
expect(mockList).toHaveBeenCalledWith('Session', { filter: { token: 'missing' } })
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('deletes session when token exists', async () => {
|
||||
mockList.mockResolvedValue({ data: [{ id: 'session_1' }] })
|
||||
mockDelete.mockResolvedValue(true)
|
||||
|
||||
const result = await deleteSessionByToken('token')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('Session', 'session_1')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getAdapter } from '../dbal-client'
|
||||
|
||||
export async function deleteSessionByToken(token: string): Promise<boolean> {
|
||||
const adapter = getAdapter()
|
||||
const result = await adapter.list('Session', { filter: { token } })
|
||||
if (!result.data.length) return false
|
||||
const session = result.data[0] as any
|
||||
await adapter.delete('Session', session.id)
|
||||
return true
|
||||
}
|
||||
25
frontends/nextjs/src/lib/db/sessions/delete-session.test.ts
Normal file
25
frontends/nextjs/src/lib/db/sessions/delete-session.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockDelete = vi.fn()
|
||||
const mockAdapter = { delete: mockDelete }
|
||||
|
||||
vi.mock('../dbal-client', () => ({
|
||||
getAdapter: () => mockAdapter,
|
||||
}))
|
||||
|
||||
import { deleteSession } from './delete-session'
|
||||
|
||||
describe('deleteSession', () => {
|
||||
beforeEach(() => {
|
||||
mockDelete.mockReset()
|
||||
})
|
||||
|
||||
it('deletes session by id', async () => {
|
||||
mockDelete.mockResolvedValue(true)
|
||||
|
||||
const result = await deleteSession('session_1')
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('Session', 'session_1')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
6
frontends/nextjs/src/lib/db/sessions/delete-session.ts
Normal file
6
frontends/nextjs/src/lib/db/sessions/delete-session.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getAdapter } from '../dbal-client'
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<boolean> {
|
||||
const adapter = getAdapter()
|
||||
return await adapter.delete('Session', sessionId)
|
||||
}
|
||||
10
frontends/nextjs/src/lib/db/sessions/get-session-by-id.ts
Normal file
10
frontends/nextjs/src/lib/db/sessions/get-session-by-id.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getAdapter } from '../dbal-client'
|
||||
import { mapSessionRecord } from './map-session-record'
|
||||
import type { Session } from './types'
|
||||
|
||||
export async function getSessionById(sessionId: string): Promise<Session | null> {
|
||||
const adapter = getAdapter()
|
||||
const record = await adapter.read('Session', sessionId)
|
||||
if (!record) return null
|
||||
return mapSessionRecord(record)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockList = vi.fn()
|
||||
const mockDelete = vi.fn()
|
||||
const mockAdapter = { list: mockList, delete: mockDelete }
|
||||
|
||||
vi.mock('../dbal-client', () => ({
|
||||
getAdapter: () => mockAdapter,
|
||||
}))
|
||||
|
||||
import { getSessionByToken } from './get-session-by-token'
|
||||
|
||||
describe('getSessionByToken', () => {
|
||||
beforeEach(() => {
|
||||
mockList.mockReset()
|
||||
mockDelete.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns null when no session exists', async () => {
|
||||
mockList.mockResolvedValue({ data: [] })
|
||||
|
||||
const result = await getSessionByToken('token')
|
||||
|
||||
expect(mockList).toHaveBeenCalledWith('Session', { filter: { token: 'token' } })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns session when not expired', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1000)
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'session_1',
|
||||
userId: 'user_1',
|
||||
token: 'token',
|
||||
expiresAt: BigInt(2000),
|
||||
createdAt: BigInt(500),
|
||||
lastActivity: BigInt(900),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await getSessionByToken('token')
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'session_1',
|
||||
userId: 'user_1',
|
||||
token: 'token',
|
||||
expiresAt: 2000,
|
||||
createdAt: 500,
|
||||
lastActivity: 900,
|
||||
})
|
||||
expect(mockDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes and returns null when expired', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(3000)
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'session_2',
|
||||
userId: 'user_2',
|
||||
token: 'expired',
|
||||
expiresAt: BigInt(2000),
|
||||
createdAt: BigInt(1000),
|
||||
lastActivity: BigInt(1500),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await getSessionByToken('expired')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockDelete).toHaveBeenCalledWith('Session', 'session_2')
|
||||
})
|
||||
})
|
||||
18
frontends/nextjs/src/lib/db/sessions/get-session-by-token.ts
Normal file
18
frontends/nextjs/src/lib/db/sessions/get-session-by-token.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getAdapter } from '../dbal-client'
|
||||
import { deleteSession } from './delete-session'
|
||||
import { mapSessionRecord } from './map-session-record'
|
||||
import type { Session } from './types'
|
||||
|
||||
export async function getSessionByToken(token: string): Promise<Session | null> {
|
||||
const adapter = getAdapter()
|
||||
const result = await adapter.list('Session', { filter: { token } })
|
||||
if (!result.data.length) return null
|
||||
|
||||
const session = mapSessionRecord(result.data[0])
|
||||
if (session.expiresAt <= Date.now()) {
|
||||
await deleteSession(session.id)
|
||||
return null
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
8
frontends/nextjs/src/lib/db/sessions/index.ts
Normal file
8
frontends/nextjs/src/lib/db/sessions/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { createSession } from './create-session'
|
||||
export { getSessionById } from './get-session-by-id'
|
||||
export { getSessionByToken } from './get-session-by-token'
|
||||
export { updateSession } from './update-session'
|
||||
export { deleteSession } from './delete-session'
|
||||
export { deleteSessionByToken } from './delete-session-by-token'
|
||||
export { listSessions } from './list-sessions'
|
||||
export type { Session, CreateSessionInput, UpdateSessionInput, ListSessionsOptions } from './types'
|
||||
88
frontends/nextjs/src/lib/db/sessions/list-sessions.test.ts
Normal file
88
frontends/nextjs/src/lib/db/sessions/list-sessions.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockList = vi.fn()
|
||||
const mockAdapter = { list: mockList }
|
||||
|
||||
vi.mock('../dbal-client', () => ({
|
||||
getAdapter: () => mockAdapter,
|
||||
}))
|
||||
|
||||
import { listSessions } from './list-sessions'
|
||||
|
||||
describe('listSessions', () => {
|
||||
beforeEach(() => {
|
||||
mockList.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('filters out expired sessions by default', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(2000)
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'session_active',
|
||||
userId: 'user_1',
|
||||
token: 'active',
|
||||
expiresAt: BigInt(3000),
|
||||
createdAt: BigInt(1000),
|
||||
lastActivity: BigInt(1500),
|
||||
},
|
||||
{
|
||||
id: 'session_expired',
|
||||
userId: 'user_1',
|
||||
token: 'expired',
|
||||
expiresAt: BigInt(1500),
|
||||
createdAt: BigInt(900),
|
||||
lastActivity: BigInt(1200),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await listSessions({ userId: 'user_1' })
|
||||
|
||||
expect(mockList).toHaveBeenCalledWith('Session', { filter: { userId: 'user_1' } })
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'session_active',
|
||||
userId: 'user_1',
|
||||
token: 'active',
|
||||
expiresAt: 3000,
|
||||
createdAt: 1000,
|
||||
lastActivity: 1500,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('returns expired sessions when includeExpired is true', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(2000)
|
||||
mockList.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'session_expired',
|
||||
userId: 'user_2',
|
||||
token: 'expired',
|
||||
expiresAt: BigInt(1500),
|
||||
createdAt: BigInt(900),
|
||||
lastActivity: BigInt(1200),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await listSessions({ includeExpired: true })
|
||||
|
||||
expect(mockList).toHaveBeenCalledWith('Session')
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'session_expired',
|
||||
userId: 'user_2',
|
||||
token: 'expired',
|
||||
expiresAt: 1500,
|
||||
createdAt: 900,
|
||||
lastActivity: 1200,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
19
frontends/nextjs/src/lib/db/sessions/list-sessions.ts
Normal file
19
frontends/nextjs/src/lib/db/sessions/list-sessions.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { getAdapter } from '../dbal-client'
|
||||
import { mapSessionRecord } from './map-session-record'
|
||||
import type { ListSessionsOptions, Session } from './types'
|
||||
|
||||
export async function listSessions(options?: ListSessionsOptions): Promise<Session[]> {
|
||||
const adapter = getAdapter()
|
||||
const result = options?.userId
|
||||
? await adapter.list('Session', { filter: { userId: options.userId } })
|
||||
: await adapter.list('Session')
|
||||
|
||||
const sessions = result.data.map(mapSessionRecord)
|
||||
|
||||
if (options?.includeExpired) {
|
||||
return sessions
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
return sessions.filter((session) => session.expiresAt > now)
|
||||
}
|
||||
12
frontends/nextjs/src/lib/db/sessions/map-session-record.ts
Normal file
12
frontends/nextjs/src/lib/db/sessions/map-session-record.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Session } from './types'
|
||||
|
||||
export function mapSessionRecord(record: any): Session {
|
||||
return {
|
||||
id: record.id,
|
||||
userId: record.userId,
|
||||
token: record.token,
|
||||
expiresAt: Number(record.expiresAt),
|
||||
createdAt: Number(record.createdAt),
|
||||
lastActivity: Number(record.lastActivity),
|
||||
}
|
||||
}
|
||||
28
frontends/nextjs/src/lib/db/sessions/types.ts
Normal file
28
frontends/nextjs/src/lib/db/sessions/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface Session {
|
||||
id: string
|
||||
userId: string
|
||||
token: string
|
||||
expiresAt: number
|
||||
createdAt: number
|
||||
lastActivity: number
|
||||
}
|
||||
|
||||
export interface CreateSessionInput {
|
||||
userId: string
|
||||
expiresAt: number
|
||||
id?: string
|
||||
token?: string
|
||||
createdAt?: number
|
||||
lastActivity?: number
|
||||
}
|
||||
|
||||
export interface UpdateSessionInput {
|
||||
token?: string
|
||||
expiresAt?: number
|
||||
lastActivity?: number
|
||||
}
|
||||
|
||||
export interface ListSessionsOptions {
|
||||
userId?: string
|
||||
includeExpired?: boolean
|
||||
}
|
||||
46
frontends/nextjs/src/lib/db/sessions/update-session.test.ts
Normal file
46
frontends/nextjs/src/lib/db/sessions/update-session.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockUpdate = vi.fn()
|
||||
const mockAdapter = { update: mockUpdate }
|
||||
|
||||
vi.mock('../dbal-client', () => ({
|
||||
getAdapter: () => mockAdapter,
|
||||
}))
|
||||
|
||||
import { updateSession } from './update-session'
|
||||
|
||||
describe('updateSession', () => {
|
||||
beforeEach(() => {
|
||||
mockUpdate.mockReset()
|
||||
})
|
||||
|
||||
it('updates session fields and maps result', async () => {
|
||||
mockUpdate.mockResolvedValue({
|
||||
id: 'session_1',
|
||||
userId: 'user_1',
|
||||
token: 'token',
|
||||
expiresAt: BigInt(5000),
|
||||
createdAt: BigInt(1000),
|
||||
lastActivity: BigInt(4000),
|
||||
})
|
||||
|
||||
const result = await updateSession('session_1', {
|
||||
expiresAt: 5000,
|
||||
lastActivity: 4000,
|
||||
})
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith('Session', 'session_1', {
|
||||
expiresAt: BigInt(5000),
|
||||
lastActivity: BigInt(4000),
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'session_1',
|
||||
userId: 'user_1',
|
||||
token: 'token',
|
||||
expiresAt: 5000,
|
||||
createdAt: 1000,
|
||||
lastActivity: 4000,
|
||||
})
|
||||
})
|
||||
})
|
||||
13
frontends/nextjs/src/lib/db/sessions/update-session.ts
Normal file
13
frontends/nextjs/src/lib/db/sessions/update-session.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getAdapter } from '../dbal-client'
|
||||
import type { Session, UpdateSessionInput } from './types'
|
||||
import { mapSessionRecord } from './map-session-record'
|
||||
|
||||
export async function updateSession(sessionId: string, input: UpdateSessionInput): Promise<Session> {
|
||||
const adapter = getAdapter()
|
||||
const record = await adapter.update('Session', sessionId, {
|
||||
...(input.token ? { token: input.token } : {}),
|
||||
...(input.expiresAt !== undefined ? { expiresAt: BigInt(input.expiresAt) } : {}),
|
||||
...(input.lastActivity !== undefined ? { lastActivity: BigInt(input.lastActivity) } : {}),
|
||||
})
|
||||
return mapSessionRecord(record)
|
||||
}
|
||||
63
frontends/nextjs/src/lib/db/users/get-user-by-email.test.ts
Normal file
63
frontends/nextjs/src/lib/db/users/get-user-by-email.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockFindFirst = vi.fn()
|
||||
const mockAdapter = { findFirst: mockFindFirst }
|
||||
|
||||
vi.mock('../dbal-client', () => ({
|
||||
getAdapter: () => mockAdapter,
|
||||
}))
|
||||
|
||||
import { getUserByEmail } from './get-user-by-email'
|
||||
|
||||
describe('getUserByEmail', () => {
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset()
|
||||
})
|
||||
|
||||
it('returns null when user not found', async () => {
|
||||
mockFindFirst.mockResolvedValue(null)
|
||||
|
||||
const result = await getUserByEmail('missing@example.com')
|
||||
|
||||
expect(mockFindFirst).toHaveBeenCalledWith('User', { where: { email: 'missing@example.com' } })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns user when found', async () => {
|
||||
mockFindFirst.mockResolvedValue({
|
||||
id: 'user_2',
|
||||
username: 'bob',
|
||||
email: 'bob@example.com',
|
||||
role: 'user',
|
||||
profilePicture: 'pic.png',
|
||||
bio: null,
|
||||
createdAt: BigInt(2000),
|
||||
tenantId: 'tenant_2',
|
||||
isInstanceOwner: true,
|
||||
})
|
||||
|
||||
const result = await getUserByEmail('bob@example.com')
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'user_2',
|
||||
username: 'bob',
|
||||
email: 'bob@example.com',
|
||||
role: 'user',
|
||||
profilePicture: 'pic.png',
|
||||
bio: undefined,
|
||||
createdAt: 2000,
|
||||
tenantId: 'tenant_2',
|
||||
isInstanceOwner: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('includes tenant filter when provided', async () => {
|
||||
mockFindFirst.mockResolvedValue(null)
|
||||
|
||||
await getUserByEmail('bob@example.com', { tenantId: 'tenant_2' })
|
||||
|
||||
expect(mockFindFirst).toHaveBeenCalledWith('User', {
|
||||
where: { email: 'bob@example.com', tenantId: 'tenant_2' },
|
||||
})
|
||||
})
|
||||
})
|
||||
30
frontends/nextjs/src/lib/db/users/get-user-by-email.ts
Normal file
30
frontends/nextjs/src/lib/db/users/get-user-by-email.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getAdapter } from '../dbal-client'
|
||||
import type { User } from '../../types/level-types'
|
||||
|
||||
export async function getUserByEmail(
|
||||
email: string,
|
||||
options?: { tenantId?: string }
|
||||
): Promise<User | null> {
|
||||
const adapter = getAdapter()
|
||||
const record = await adapter.findFirst('User', {
|
||||
where: {
|
||||
email,
|
||||
...(options?.tenantId ? { tenantId: options.tenantId } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!record) return null
|
||||
|
||||
const user = record as any
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role as any,
|
||||
profilePicture: user.profilePicture || undefined,
|
||||
bio: user.bio || undefined,
|
||||
createdAt: Number(user.createdAt),
|
||||
tenantId: user.tenantId || undefined,
|
||||
isInstanceOwner: user.isInstanceOwner,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockFindFirst = vi.fn()
|
||||
const mockAdapter = { findFirst: mockFindFirst }
|
||||
|
||||
vi.mock('../dbal-client', () => ({
|
||||
getAdapter: () => mockAdapter,
|
||||
}))
|
||||
|
||||
import { getUserByUsername } from './get-user-by-username'
|
||||
|
||||
describe('getUserByUsername', () => {
|
||||
beforeEach(() => {
|
||||
mockFindFirst.mockReset()
|
||||
})
|
||||
|
||||
it('returns null when user not found', async () => {
|
||||
mockFindFirst.mockResolvedValue(null)
|
||||
|
||||
const result = await getUserByUsername('missing')
|
||||
|
||||
expect(mockFindFirst).toHaveBeenCalledWith('User', { where: { username: 'missing' } })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns user when found', async () => {
|
||||
mockFindFirst.mockResolvedValue({
|
||||
id: 'user_1',
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
role: 'admin',
|
||||
profilePicture: null,
|
||||
bio: 'Bio',
|
||||
createdAt: BigInt(1000),
|
||||
tenantId: null,
|
||||
isInstanceOwner: false,
|
||||
})
|
||||
|
||||
const result = await getUserByUsername('alice')
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'user_1',
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
role: 'admin',
|
||||
profilePicture: undefined,
|
||||
bio: 'Bio',
|
||||
createdAt: 1000,
|
||||
tenantId: undefined,
|
||||
isInstanceOwner: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('includes tenant filter when provided', async () => {
|
||||
mockFindFirst.mockResolvedValue(null)
|
||||
|
||||
await getUserByUsername('alice', { tenantId: 'tenant_1' })
|
||||
|
||||
expect(mockFindFirst).toHaveBeenCalledWith('User', {
|
||||
where: { username: 'alice', tenantId: 'tenant_1' },
|
||||
})
|
||||
})
|
||||
})
|
||||
30
frontends/nextjs/src/lib/db/users/get-user-by-username.ts
Normal file
30
frontends/nextjs/src/lib/db/users/get-user-by-username.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getAdapter } from '../dbal-client'
|
||||
import type { User } from '../../types/level-types'
|
||||
|
||||
export async function getUserByUsername(
|
||||
username: string,
|
||||
options?: { tenantId?: string }
|
||||
): Promise<User | null> {
|
||||
const adapter = getAdapter()
|
||||
const record = await adapter.findFirst('User', {
|
||||
where: {
|
||||
username,
|
||||
...(options?.tenantId ? { tenantId: options.tenantId } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!record) return null
|
||||
|
||||
const user = record as any
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role as any,
|
||||
profilePicture: user.profilePicture || undefined,
|
||||
bio: user.bio || undefined,
|
||||
createdAt: Number(user.createdAt),
|
||||
tenantId: user.tenantId || undefined,
|
||||
isInstanceOwner: user.isInstanceOwner,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export { getUsers } from './get-users'
|
||||
export { getUserById } from './get-user-by-id'
|
||||
export { getUserByUsername } from './get-user-by-username'
|
||||
export { getUserByEmail } from './get-user-by-email'
|
||||
export { setUsers } from './set-users'
|
||||
export { addUser } from './add-user'
|
||||
export { updateUser } from './update-user'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseWorkflowRunLogsOptions } from './parse-workflow-run-logs-options'
|
||||
|
||||
describe('parseWorkflowRunLogsOptions', () => {
|
||||
it('defaults when params are empty', () => {
|
||||
const params = new URLSearchParams()
|
||||
const result = parseWorkflowRunLogsOptions(params)
|
||||
|
||||
expect(result).toEqual({
|
||||
runName: 'Workflow Run',
|
||||
includeLogs: true,
|
||||
jobLimit: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('parses includeLogs false values', () => {
|
||||
const params = new URLSearchParams({
|
||||
includeLogs: 'false',
|
||||
})
|
||||
const result = parseWorkflowRunLogsOptions(params)
|
||||
|
||||
expect(result.includeLogs).toBe(false)
|
||||
})
|
||||
|
||||
it('clamps jobLimit within bounds', () => {
|
||||
const params = new URLSearchParams({
|
||||
jobLimit: '250',
|
||||
})
|
||||
const result = parseWorkflowRunLogsOptions(params)
|
||||
|
||||
expect(result.jobLimit).toBe(100)
|
||||
})
|
||||
|
||||
it('trims runName and preserves includeLogs true', () => {
|
||||
const params = new URLSearchParams({
|
||||
runName: ' Deploy Pipeline ',
|
||||
includeLogs: '1',
|
||||
jobLimit: '5',
|
||||
})
|
||||
const result = parseWorkflowRunLogsOptions(params)
|
||||
|
||||
expect(result).toEqual({
|
||||
runName: 'Deploy Pipeline',
|
||||
includeLogs: true,
|
||||
jobLimit: 5,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
export type WorkflowRunLogsOptions = {
|
||||
runName: string
|
||||
includeLogs: boolean
|
||||
jobLimit?: number
|
||||
}
|
||||
|
||||
export const parseWorkflowRunLogsOptions = (
|
||||
params: URLSearchParams
|
||||
): WorkflowRunLogsOptions => {
|
||||
const runName = params.get('runName')?.trim() || 'Workflow Run'
|
||||
const includeLogsParam = params.get('includeLogs')?.trim().toLowerCase()
|
||||
const includeLogs = includeLogsParam
|
||||
? !['false', '0', 'no'].includes(includeLogsParam)
|
||||
: true
|
||||
const jobLimitParam = params.get('jobLimit')
|
||||
const parsedJobLimit = jobLimitParam ? Number(jobLimitParam) : Number.NaN
|
||||
const jobLimit = Number.isFinite(parsedJobLimit)
|
||||
? Math.max(1, Math.min(100, Math.floor(parsedJobLimit)))
|
||||
: undefined
|
||||
|
||||
return {
|
||||
runName,
|
||||
includeLogs,
|
||||
jobLimit,
|
||||
}
|
||||
}
|
||||
@@ -80,25 +80,25 @@
|
||||
"package.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^28.0.3",
|
||||
"@rollup/plugin-commonjs": "^29.0.0",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@rollup/plugin-replace": "^6.0.2",
|
||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||
"@rollup/plugin-replace": "^6.0.3",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.3.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/body-parser": "^1.19.6",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/react": "^19.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"rollup": "^4.35.0",
|
||||
"rollup-plugin-delete": "^3.0.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"jsdom": "^27.3.0",
|
||||
"rollup": "^4.54.0",
|
||||
"rollup-plugin-delete": "^3.0.2",
|
||||
"tslib": "^2.8.1",
|
||||
"ulid": "^3.0.0",
|
||||
"vitest": "^3.0.9",
|
||||
"zod": "^3.24.2"
|
||||
"ulid": "^3.0.2",
|
||||
"vitest": "^4.0.16",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
@@ -112,8 +112,8 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"body-parser": "^1.20.3",
|
||||
"express": "^5.2.0",
|
||||
"octokit": "^5.0.3"
|
||||
"body-parser": "^2.2.1",
|
||||
"express": "^5.2.1",
|
||||
"octokit": "^5.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,19 @@ model Credential {
|
||||
passwordHash String
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt BigInt
|
||||
createdAt BigInt
|
||||
lastActivity BigInt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Workflow {
|
||||
id String @id
|
||||
name String
|
||||
|
||||
Reference in New Issue
Block a user