diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index cf801f2a..7a4d7bd1 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -522,7 +522,7 @@ x-application: ### 4.1 Create Organisation User **Endpoint:** `POST /organisation/user/create` -**Description:** Adds a user to an organisation with specified access level. +**Description:** Adds a user to an organisation with specified access level. If the user exists in the system, they are immediately added to the organization. If the user doesn't exist, an invitation is created and sent via email with a secure token that the user can use to accept the invitation. **Headers:** ```json @@ -557,16 +557,45 @@ x-application: { "type": "object", "properties": { + "user": { + "type": "string", + "description": "Username that was processed" + }, "success": { - "type": "boolean" + "type": "boolean", + "description": "Whether the operation was successful" }, - "message": { - "type": "string" + "operation": { + "type": "string", + "enum": ["add", "invite_created", "invite_updated"], + "description": "Type of operation performed" } } } ``` +**Operation Types:** +- `add`: User existed and was directly added to the organization +- `invite_created`: User didn't exist, new invitation was created and email sent +- `invite_updated`: User didn't exist but had a pending invite, existing invite was updated and email re-sent + +**Example Responses:** +```json +{ + "user": "existing_user", + "success": true, + "operation": "add" +} +``` + +```json +{ + "user": "new_user@example.com", + "success": true, + "operation": "invite_created" +} +``` + ### 4.2 Update Organisation User **Endpoint:** `POST /organisation/user/update` @@ -697,9 +726,273 @@ x-application: --- -## 5. File Management +## 5. Invite Management + +### 5.1 Accept Organization Invite +**Endpoint:** `POST /organisation/user/invite/accept` + +**Description:** Accepts an organization invitation using an invite token. The authenticated user will be added to the organization with the role specified in the invite. + +**Headers:** +```json +{ + "Content-Type": "application/json", + "Authorization": "Bearer " +} +``` + +**Request Body Schema:** +```json +{ + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Invite token received via email" + } + }, + "required": ["token"] +} +``` + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the invite was accepted successfully" + }, + "message": { + "type": "string", + "description": "Success message" + }, + "organization": { + "type": "string", + "description": "Organization name that was joined" + }, + "role": { + "type": "string", + "enum": ["admin", "write", "read"], + "description": "Role assigned in the organization" + } + } +} +``` + +**Example Response:** +```json +{ + "success": true, + "message": "Successfully joined organization my-company as admin", + "organization": "my-company", + "role": "admin" +} +``` + +**Error Cases:** +- `400 Bad Request`: Invalid or expired invite token +- `401 Unauthorized`: Missing authentication or invite email doesn't match authenticated user +- `404 Not Found`: User not found in system +- `500 Internal Server Error`: Failed to add user to organization + +### 5.2 List Organization Invites +**Endpoint:** `GET /organisation/user/invite/list` + +**Description:** Lists all invites for the authenticated user's organization with search and pagination capabilities. Requires WRITE permission for the organization. + +**Headers:** +```json +{ + "Content-Type": "application/json", + "Authorization": "Bearer ", + "x-organisation": "" +} +``` + +**Query Parameters:** +- `search` (optional): Search term for email or role filtering +- `status` (optional): Filter by invite status (`pending`, `accepted`, `declined`, `expired`) +- `page` (optional): Page number (default: 1) +- `per_page` (optional): Items per page (default: 10, max: 100) + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "invites": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Invite UUID" + }, + "email": { + "type": "string", + "description": "Email address of invitee" + }, + "role": { + "type": "string", + "enum": ["admin", "write", "read"], + "description": "Role to be assigned" + }, + "status": { + "type": "string", + "enum": ["pending", "accepted", "declined", "expired"], + "description": "Current invite status" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the invite was created/last updated" + } + } + } + }, + "pagination": { + "type": "object", + "properties": { + "current_page": { + "type": "integer", + "description": "Current page number" + }, + "per_page": { + "type": "integer", + "description": "Items per page" + }, + "total_items": { + "type": "integer", + "description": "Total number of invites" + }, + "total_pages": { + "type": "integer", + "description": "Total number of pages" + } + } + } + } +} +``` + +**Example Response:** +```json +{ + "invites": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "email": "user@example.com", + "role": "admin", + "status": "pending", + "created_at": "2025-11-03T10:30:00Z" + } + ], + "pagination": { + "current_page": 1, + "per_page": 10, + "total_items": 1, + "total_pages": 1 + } +} +``` + +**Example Usage:** +- List all invites: `GET /organisation/user/invite/list` +- Search by email: `GET /organisation/user/invite/list?search=john@example.com` +- Filter pending invites: `GET /organisation/user/invite/list?status=pending` +- Search admin roles: `GET /organisation/user/invite/list?search=admin` +- Paginated results: `GET /organisation/user/invite/list?page=2&per_page=20` + +### 5.3 Revoke Organization Invite +**Endpoint:** `POST /organisation/user/invite/{invite_id}/revoke` + +**Description:** Revokes (expires) an existing organization invite. Requires WRITE permission for the organization. Only invites belonging to the authenticated user's organization can be revoked. + +**Headers:** +```json +{ + "Content-Type": "application/json", + "Authorization": "Bearer ", + "x-organisation": "" +} +``` + +**Path Parameters:** +- `invite_id`: UUID of the invite to revoke + +**Response Schema:** +```json +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Invite UUID" + }, + "org_id": { + "type": "string", + "description": "Organization ID" + }, + "email": { + "type": "string", + "description": "Email address of invitee" + }, + "role": { + "type": "string", + "enum": ["admin", "write", "read"], + "description": "Role that was to be assigned" + }, + "status": { + "type": "string", + "value": "expired", + "description": "Updated status (will be 'expired')" + }, + "token": { + "type": "string", + "description": "Invite token (now invalid)" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the invite was created" + } + } +} +``` + +**Error Cases:** +- `400 Bad Request`: Invalid invite ID format +- `403 Forbidden`: No permission to revoke invite (not in same organization) +- `404 Not Found`: Invite not found +- `500 Internal Server Error`: Database error + +**Notes:** +1. **Invite Tokens**: When users don't exist in the system, the `/create` endpoint now automatically creates database invites with secure tokens and sends email notifications. + +2. **Email Integration**: All invite endpoints integrate with the email system using Tera templates. Invite emails include: + - Organization name + - Role being offered + - Secure invite token + - Accept link (when implemented in frontend) + +3. **Token Expiry**: Invite tokens automatically expire after 7 days from creation. Expired tokens cannot be used to accept invites. + +4. **Database-Driven**: All invites are stored in the PostgreSQL database with proper enum types for roles and statuses, ensuring data consistency. + +5. **Security Features**: + - Tokens are 64-character cryptographically secure random strings + - Email verification ensures invites can only be accepted by the intended recipient + - JWT authentication required for all operations + - Organization-level permissions enforced + +--- + +## 6. File Management -### 5.1 Create File +### 6.1 Create File **Endpoint:** `POST /file` **Description:** Creates a new file entry by providing a URL. @@ -782,7 +1075,7 @@ x-application: } ``` -### 5.2 Bulk Create Files +### 6.2 Bulk Create Files **Endpoint:** `POST /file/bulk` **Description:** Creates multiple file entries at once. @@ -859,7 +1152,7 @@ x-application: } ``` -### 5.3 Get File +### 6.3 Get File **Endpoint:** `GET /file` **Description:** Gets details of a specific file. @@ -879,7 +1172,7 @@ x-application: **Response Schema:** Same as file creation response. -### 5.4 List Files +### 6.4 List Files **Endpoint:** `GET /file/list` **Description:** Lists all files for an application with pagination and search. @@ -924,7 +1217,7 @@ x-application: } ``` -### 5.5 Update File Tag +### 6.5 Update File Tag **Endpoint:** `PATCH /file/{file_key}` **Description:** Updates the tag of an existing file. @@ -960,9 +1253,9 @@ x-application: --- -## 6. Package Management +## 7. Package Management -### 6.1 Create Package +### 7.1 Create Package **Endpoint:** `POST /packages` **Description:** Creates a new package containing multiple files. @@ -1026,7 +1319,7 @@ x-application: } ``` -### 6.2 List Packages +### 7.2 List Packages **Endpoint:** `GET /packages/list` **Description:** Lists packages for an application with pagination. @@ -1070,7 +1363,7 @@ x-application: } ``` -### 6.3 Get Individual Package +### 7.3 Get Individual Package **Endpoint:** `GET /packages` **Description:** Gets details of a specific package. @@ -1114,9 +1407,9 @@ x-application: --- -## 7. Dimension Management +## 8. Dimension Management -### 7.1 Create Dimension +### 8.1 Create Dimension **Endpoint:** `POST /organisations/applications/dimension/create` **Description:** Creates a new dimension for application configuration. Supports both standard dimensions and cohort dimensions. @@ -1215,7 +1508,7 @@ x-application: } ``` -### 7.2 List Dimensions +### 8.2 List Dimensions **Endpoint:** `GET /organisations/applications/dimension/list` **Description:** Lists all dimensions for an application. @@ -1275,7 +1568,7 @@ x-application: } ``` -### 7.3 Update Dimension +### 8.3 Update Dimension **Endpoint:** `PUT /organisations/applications/dimension/{dimension_name}` **Description:** Updates a dimension's properties. @@ -1338,7 +1631,7 @@ x-application: } ``` -### 7.4 Delete Dimension +### 8.4 Delete Dimension **Endpoint:** `DELETE /organisations/applications/dimension/{dimension_name}` **Description:** Deletes a dimension from the application. @@ -1366,11 +1659,11 @@ x-application: --- -## 8. Cohort Dimension Management +## 9. Cohort Dimension Management Cohort dimensions allow you to segment users based on version ranges or group memberships. They support both checkpoint-based segmentation (using version comparisons) and group-based segmentation (using explicit member lists). -### 8.1 List Cohort Schema +### 9.1 List Cohort Schema **Endpoint:** `GET /organisations/applications/dimension/{cohort_dimension_name}/cohort` **Description:** Retrieves the schema and configuration of a cohort dimension. @@ -1416,7 +1709,7 @@ Cohort dimensions allow you to segment users based on version ranges or group me } ``` -### 8.2 Create Cohort Checkpoint +### 9.2 Create Cohort Checkpoint **Endpoint:** `POST /organisations/applications/dimension/{cohort_dimension_name}/cohort/checkpoint` **Description:** Creates a checkpoint cohort that segments users based on version comparisons (e.g., users with app version >= 2.1.0). @@ -1475,7 +1768,7 @@ Cohort dimensions allow you to segment users based on version ranges or group me } ``` -### 8.3 Create Cohort Group +### 9.3 Create Cohort Group **Endpoint:** `POST /organisations/applications/dimension/{cohort_dimension_name}/cohort/group` **Description:** Creates a group cohort that segments users based on explicit membership lists (e.g., beta testers, VIP users). @@ -1532,7 +1825,7 @@ Cohort dimensions allow you to segment users based on version ranges or group me } ``` -### 8.4 Get Cohort Group Priority +### 9.4 Get Cohort Group Priority **Endpoint:** `GET /organisations/applications/dimension/{cohort_dimension_name}/cohort/group/priority` **Description:** Retrieves the priority ordering of cohort groups. Groups with lower priority values are evaluated first. @@ -1566,7 +1859,7 @@ Cohort dimensions allow you to segment users based on version ranges or group me } ``` -### 8.5 Update Cohort Group Priority +### 9.5 Update Cohort Group Priority **Endpoint:** `PUT /organisations/applications/dimension/{cohort_dimension_name}/cohort/group/priority` **Description:** Updates the priority ordering of cohort groups. This affects the order in which groups are evaluated for user segmentation. @@ -1654,9 +1947,9 @@ Cohort dimensions allow you to segment users based on version ranges or group me --- -## 9. Release Management +## 10. Release Management -### 9.1 Create Release +### 10.1 Create Release **Endpoint:** `POST /releases` **Description:** Creates a new release with configuration and package details. @@ -1849,7 +2142,7 @@ Cohort dimensions allow you to segment users based on version ranges or group me } ``` -### 9.2 List Releases +### 10.2 List Releases **Endpoint:** `GET /releases/list` **Description:** Lists all releases for an application. @@ -1894,7 +2187,7 @@ Cohort dimensions allow you to segment users based on version ranges or group me } ``` -### 9.3 Get Individual Release +### 10.3 Get Individual Release **Endpoint:** `GET /releases/{release_id}` **Description:** Gets details of a specific release. @@ -1986,7 +2279,7 @@ Cohort dimensions allow you to segment users based on version ranges or group me } ``` -### 9.4 Ramp Release +### 10.4 Ramp Release **Endpoint:** `POST /releases/{release_id}/ramp` **Description:** Updates the traffic percentage for a release experiment. @@ -2045,7 +2338,7 @@ Cohort dimensions allow you to segment users based on version ranges or group me } ``` -### 9.5 Conclude Release +### 10.5 Conclude Release **Endpoint:** `POST /releases/{release_id}/conclude` **Description:** Concludes a release experiment by choosing a winning variant. @@ -2096,7 +2389,7 @@ Cohort dimensions allow you to segment users based on version ranges or group me } ``` -### 9.6 Serve Release Configuration (Public) +### 10.6 Serve Release Configuration (Public) **Endpoint:** `GET /release/{organisation}/{application}` **Description:** Public endpoint that serves the live release configuration for client SDKs. No authentication required. @@ -2203,9 +2496,9 @@ Cohort dimensions allow you to segment users based on version ranges or group me --- -## 10. Configuration Management +## 11. Configuration Management -### 10.1 Create Configuration +### 11.1 Create Configuration **Endpoint:** `POST /organisations/applications/config/create` **Description:** Creates application configuration. diff --git a/Cargo.lock b/Cargo.lock index 424fe345..f9c4a2fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if 1.0.3", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -365,14 +377,17 @@ dependencies = [ "http-body 1.0.1", "jsonwebtoken", "keycloak", + "lettre", "log", "r2d2", + "rand 0.8.5", "reqwest", "rustls 0.23.31", "serde", "serde_json", "sha2", "superposition_sdk", + "tera", "thiserror 2.0.16", "tokio", "tracing", @@ -423,6 +438,15 @@ version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +[[package]] +name = "ar_archive_writer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +dependencies = [ + "object 0.32.2", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -994,7 +1018,7 @@ dependencies = [ "cfg-if 1.0.3", "libc", "miniz_oxide", - "object", + "object 0.36.7", "rustc-demangle", "windows-targets 0.52.6", ] @@ -1102,6 +1126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", + "serde", ] [[package]] @@ -1168,7 +1193,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -1197,6 +1222,38 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1382,6 +1439,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1571,6 +1647,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "diesel" version = "2.2.12" @@ -1725,6 +1807,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1971,6 +2069,30 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.9.4", + "ignore", + "walkdir", +] + [[package]] name = "google-apis-common" version = "7.0.0" @@ -2069,6 +2191,16 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2107,6 +2239,17 @@ dependencies = [ "digest", ] +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if 1.0.3", + "libc", + "windows-link 0.1.3", +] + [[package]] name = "http" version = "0.2.12" @@ -2181,6 +2324,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "hyper" version = "0.14.32" @@ -2440,6 +2592,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "impl-more" version = "0.1.9" @@ -2580,6 +2748,31 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "socket2 0.6.0", + "tokio", + "url", +] + [[package]] name = "libbz2-rs-sys" version = "0.2.2" @@ -2642,6 +2835,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "libz-rs-sys" version = "0.5.2" @@ -2851,6 +3050,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -2925,6 +3133,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.36.7" @@ -3046,6 +3263,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -3072,6 +3298,87 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -3236,6 +3543,16 @@ version = "2.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" +[[package]] +name = "psm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.40" @@ -3245,6 +3562,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -3646,6 +3969,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "sasl2-sys" version = "0.1.22+2.1.28" @@ -4021,12 +4353,28 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -4075,6 +4423,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if 1.0.3", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -4195,6 +4556,28 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4613,6 +4996,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.8.1" @@ -4625,6 +5014,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4713,6 +5108,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4841,6 +5246,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.0", +] + [[package]] name = "windows-core" version = "0.61.2" diff --git a/airborne_dashboard/app/dashboard/[orgId]/[appId]/users/page.tsx b/airborne_dashboard/app/dashboard/[orgId]/[appId]/users/page.tsx index 411b0b29..ae68aac8 100644 --- a/airborne_dashboard/app/dashboard/[orgId]/[appId]/users/page.tsx +++ b/airborne_dashboard/app/dashboard/[orgId]/[appId]/users/page.tsx @@ -1,18 +1,35 @@ "use client"; +import { useState } from "react"; import useSWR from "swr"; import { apiFetch } from "@/lib/api"; import { useAppContext } from "@/providers/app-context"; -import { UserManagement, type AccessLevel, type User } from "@/components/user-management"; +import { canUpdateUsers, UserManagement, type AccessLevel, type User } from "@/components/user-management"; +import { ApplicationAccessModal } from "@/components/application-access-modal"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Users, Building2 } from "lucide-react"; +import { toastSuccess, toastError } from "@/hooks/use-toast"; +import { useRouter } from "next/navigation"; type OrgUsers = { users: User[] }; export default function ApplicationUsersPage() { const { token, org, app, getAppAccess, getOrgAccess, updateOrgs } = useAppContext(); - const { data, error, mutate } = useSWR( + const router = useRouter(); + const [isAppAccessModalOpen, setIsAppAccessModalOpen] = useState(false); + + // Application users data + const { data, isLoading, error, mutate } = useSWR( token && org ? "/organisations/applications/user/list" : null, (url: string) => apiFetch(url, {}, { token, org, app }) ); + // Organization users data (for the application access modal) + const { data: orgUsersData, isLoading: orgUsersLoading } = useSWR( + token && org ? "/organisations/user/list" : null, + (url: string) => apiFetch(url, {}, { token, org }) + ); + const addUser = async (user: string, access: AccessLevel) => { await apiFetch( "/organisations/applications/user/create", @@ -39,12 +56,122 @@ export default function ApplicationUsersPage() { updateOrgs(); }; + // Handle application access invites (grant access to existing org users) + const handleApplicationInvite = async (invites: { userId: string; role: string }[]) => { + try { + // Call the API for each user in parallel + const promises = invites.map((invite) => + apiFetch( + "/organisations/applications/user/create", + { + method: "POST", + body: { + user: invite.userId, + access: invite.role as AccessLevel, + }, + }, + { token, org, app } + ) + ); + + const results = await Promise.allSettled(promises); + const failures = results.filter((r) => r.status === "rejected"); + const successes = results.filter((r) => r.status === "fulfilled"); + + if (failures.length === 0) { + toastSuccess( + "Access Granted", + `Successfully granted ${app} access to ${invites.length} user${invites.length !== 1 ? "s" : ""}` + ); + } else if (successes.length > 0) { + toastError("Partial Success", `Granted access to ${successes.length} user(s), but ${failures.length} failed`); + } else { + throw new Error("All access grants failed"); + } + + setIsAppAccessModalOpen(false); // Close the modal + mutate(); // Refresh the users list + updateOrgs(); // Update organizations data + } catch (error: any) { + console.error("Failed to grant application access:", error); + toastError("Failed to Grant Access", error.message || "Could not grant application access"); + } + }; + + // Handle redirect to organization users page + const handleRedirectToOrgUsers = () => { + router.push(`/dashboard/${org}/users`); + }; + + if (isLoading) { + return ( + <> +
Loading users...
+ + ); + } + if (error) { return
Error loading users
; } + // Prepare org users for the application access modal (exclude users already in app) + const currentAppUsernames = new Set((data?.users || []).map((user) => user.username)); + const orgUsers = + orgUsersData?.users + ?.filter((user) => !currentAppUsernames.has(user.username)) // Filter out existing app users + ?.map((user) => ({ + id: user.username, + name: user.username, + email: user.username, // Assuming username is email for now + username: user.username, + roles: user.roles, + })) || []; + + const canUpdateAppUsers = canUpdateUsers("application", getOrgAccess(org), getAppAccess(org, app)); + const canUpdateOrgUsers = canUpdateUsers("organisation", getOrgAccess(org), getAppAccess(org, app)); + return ( -
+
+ {/* Add User to Application Card */} + {(canUpdateAppUsers || canUpdateOrgUsers) && ( + + + + + Grant Application Access + +

+ Add existing organization members to this application (excluding current app users) +

+
+ +
+ {canUpdateAppUsers && ( + + )} + {canUpdateOrgUsers && ( + + )} +
+ {canUpdateAppUsers && ( +

+ {orgUsers.length === 0 + ? "All organization users already have access to this application. Use 'Add someone to organisation' to invite new users." + : `${orgUsers.length} organization member${orgUsers.length !== 1 ? "s" : ""} available to add to this application.`} +

+ )} +
+
+ )} + + {/* Current Application Users */} + + {/* Application Access Modal */} + setIsAppAccessModalOpen(false)} + onSubmit={handleApplicationInvite} + orgUsers={orgUsers} + applicationName={app || ""} + availableRoles={["read", "write", "admin"]} + isLoading={orgUsersLoading} />
); diff --git a/airborne_dashboard/app/dashboard/[orgId]/users/page.tsx b/airborne_dashboard/app/dashboard/[orgId]/users/page.tsx index 704ba522..0c43f310 100644 --- a/airborne_dashboard/app/dashboard/[orgId]/users/page.tsx +++ b/airborne_dashboard/app/dashboard/[orgId]/users/page.tsx @@ -1,57 +1,305 @@ "use client"; +import { useState } from "react"; import useSWR from "swr"; import { apiFetch } from "@/lib/api"; +import { listInvites, revokeInvite } from "@/lib/invitation"; import { useAppContext } from "@/providers/app-context"; import { UserManagement, type AccessLevel, type User } from "@/components/user-management"; +import { InviteManagement } from "@/components/invite-management"; +import { OrganizationAccessModal } from "@/components/organization-access-modal"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Users, Mail, UserPlus } from "lucide-react"; +import { toastSuccess, toastError } from "@/hooks/use-toast"; type OrgUsers = { users: User[] }; export default function OrganisationUsersPage() { - const { token, org, getOrgAccess, updateOrgs } = useAppContext(); - const { data, error, mutate } = useSWR(token && org ? "/organisations/user/list" : null, (url: string) => + const { token, org, getOrgAccess, updateOrgs, organisations, config } = useAppContext(); + const [activeTab, setActiveTab] = useState("users"); + const [isOrgAccessModalOpen, setIsOrgAccessModalOpen] = useState(false); + + // Get applications for the current organization + const currentOrgData = organisations.find((o) => o.name === org); + const availableApplications = + currentOrgData?.applications.map((app) => ({ + id: app.application, + name: app.application, + description: `Application: ${app.application}`, + })) || []; + + // Invitation state + const [inviteSearchTerm, setInviteSearchTerm] = useState(""); + const [inviteStatusFilter, setInviteStatusFilter] = useState("all"); + const [invitePage, setInvitePage] = useState(1); + const [inviteLimit] = useState(10); + + // Users data + const { + data: usersData, + isLoading: usersLoading, + error: usersError, + mutate: mutateUsers, + } = useSWR(token && org ? "/organisations/user/list" : null, (url: string) => apiFetch(url, {}, { token, org }) ); + // Invitations data with pagination and filters + const { + data: invitesData, + isLoading: invitesLoading, + error: invitesError, + mutate: mutateInvites, + } = useSWR( + token && org ? `invites-${org}-${invitePage}-${inviteLimit}-${inviteSearchTerm}-${inviteStatusFilter}` : null, + () => + listInvites(token!, org!, { + page: invitePage, + per_page: inviteLimit, + search: inviteSearchTerm || undefined, + status: inviteStatusFilter !== "all" ? inviteStatusFilter : undefined, + }), + { + refreshInterval: 30000, // Refresh every 30 seconds + } + ); + const addUser = async (user: string, access: AccessLevel) => { - await apiFetch("/organisations/user/create", { method: "POST", body: { user, access } }, { token, org }); - mutate(); + await apiFetch("/organisations/user/invite", { method: "POST", body: { user, access } }, { token, org }); + mutateUsers(); updateOrgs(); }; const updateUser = async (user: string, access: AccessLevel) => { await apiFetch("/organisations/user/update", { method: "POST", body: { user, access } }, { token, org }); - mutate(); + mutateUsers(); updateOrgs(); }; const removeUser = async (user: string) => { await apiFetch("/organisations/user/remove", { method: "POST", body: { user } }, { token, org }); - mutate(); + mutateUsers(); updateOrgs(); }; const transferOwnership = async (user: string) => { await apiFetch("/organisations/user/transfer-ownership", { method: "POST", body: { user } }, { token, org }); - mutate(); + mutateUsers(); updateOrgs(); }; - if (error) { + const handleRevokeInvite = async (inviteId: string) => { + try { + await revokeInvite(inviteId, token!, org!); + toastSuccess("Invitation Revoked", "The invitation has been successfully revoked"); + mutateInvites(); + } catch (error: any) { + toastError("Failed to Revoke", error.message || "Could not revoke the invitation"); + } + }; + + // Handle invitation search with debouncing + const handleInviteSearchChange = (search: string) => { + setInviteSearchTerm(search); + setInvitePage(1); // Reset to first page when searching + }; + + // Handle status filter change + const handleInviteStatusFilterChange = (status: string) => { + setInviteStatusFilter(status); + setInvitePage(1); // Reset to first page when filtering + }; + + // Handle page change + const handleInvitePageChange = (page: number) => { + setInvitePage(page); + }; + + // Handle organization invites (new functionality) + const handleOrganizationInvite = async (invite: { + email: string; + orgRole: string; + applications: { name: string; level: string }[]; + }) => { + try { + // Call the invite API with applications field matching backend structure + await apiFetch( + config?.organisation_invite_enabled ? "/organisations/user/invite" : "/organisations/user/create", + { + method: "POST", + body: { + user: invite.email, + access: invite.orgRole as AccessLevel, + applications: invite.applications, // ApplicationAccess array with name and level + }, + }, + { token, org } + ); + + toastSuccess( + config?.organisation_invite_enabled ? "User Invited" : "User Added", + `Successfully ${config?.organisation_invite_enabled ? "invited" : "added"} ${invite.email} to the organization with access to ${invite.applications.length} application${invite.applications.length !== 1 ? "s" : ""}` + ); + + setIsOrgAccessModalOpen(false); // Close the modal + mutateUsers(); // Refresh the users list + mutateInvites(); // Refresh the invites list + updateOrgs(); // Update organizations data + } catch (error: any) { + console.error(`Failed to ${config?.organisation_invite_enabled ? "invite" : "add"} user to organization:`, error); + toastError( + `Failed to ${config?.organisation_invite_enabled ? "invite" : "add"} User`, + error.message || + `Could not ${config?.organisation_invite_enabled ? "invite" : "add"} the user to the organization` + ); + } + }; + + // Check user permissions + const currentUserAccess = getOrgAccess(org); + const canManageInvites = currentUserAccess.includes("admin") || currentUserAccess.includes("owner"); + + if (usersLoading) { + return
Loading...
; + } + + if (usersError) { return
Error loading users
; } return ( -
- + {/* Header */} +
+

Organization Management

+

Manage users, roles, and invitations for your organization

+
+ + {/* Tabs */} + + + + + Users + {usersData?.users && usersData.users.length > 0 && ( + + {usersData.users.length} + + )} + + {config?.organisation_invite_enabled && ( + + + Invitations + {invitesData?.pagination && invitesData.pagination.total_items > 0 && ( + + {invitesData.pagination.total_items} + + )} + + )} + + + + {usersLoading ? ( + <>Loading... + ) : usersError ? ( + + +
Error loading users. Please try again.
+
+
+ ) : ( +
+ {/* Invite to Organization Section */} + {canManageInvites && ( + + + + + Add New Users + + Add new users to this organization with application access + + + + + + )} + + {/* Organization Users Management */} + mutateInvites()} + title="Organization Users" + description="Manage users and their access levels for this organization" + entityType="organisation" + hideAddUserButton={true} + /> +
+ )} +
+ + {config?.organisation_invite_enabled && ( + + {!canManageInvites ? ( + + + Access Restricted + You need admin or owner permissions to manage invitations. + + + ) : invitesLoading ? ( + + +
Loading invitations...
+
+
+ ) : invitesError ? ( + + +
Error loading invitations. Please try again.
+
+
+ ) : ( + mutateInvites()} + onSearchChange={handleInviteSearchChange} + onStatusFilterChange={handleInviteStatusFilterChange} + onPageChange={handleInvitePageChange} + searchTerm={inviteSearchTerm} + statusFilter={inviteStatusFilter} + isLoading={invitesLoading} + showInviteButtons={false} + entityType="organization" + organizationName={org || ""} + applications={availableApplications} + /> + )} +
+ )} +
+ + {/* Organization Access Modal */} + setIsOrgAccessModalOpen(false)} + onSubmit={handleOrganizationInvite} + applications={availableApplications} + organizationName={org || ""} />
); diff --git a/airborne_dashboard/app/invitation/[inviteToken]/page.tsx b/airborne_dashboard/app/invitation/[inviteToken]/page.tsx new file mode 100644 index 00000000..191fc7a0 --- /dev/null +++ b/airborne_dashboard/app/invitation/[inviteToken]/page.tsx @@ -0,0 +1,399 @@ +"use client"; + +import type React from "react"; +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { AlertCircle, Mail, Building, UserCheck, UserX, Loader2 } from "lucide-react"; +import Link from "next/link"; +import Image from "next/image"; +import { + validateInviteToken as apiValidateInviteToken, + acceptInvite as apiAcceptInvite, + declineInvite as apiDeclineInvite, +} from "@/lib/invitation"; +import { useAppContext } from "@/providers/app-context"; +import { toastSuccess, toastError } from "@/hooks/use-toast"; +import { useRouter, useParams } from "next/navigation"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +interface InviteDetails { + invite_id: string; + email: string; + organization: string; + role: string; + status: string; + created_at: string; + inviter?: string; +} + +type InviteStatus = "loading" | "valid" | "invalid" | "expired" | "accepted" | "permission_denied"; + +export default function InvitationPage() { + const router = useRouter(); + const params = useParams(); + const { user, token, updateOrgs, config } = useAppContext(); + const [inviteStatus, setInviteStatus] = useState("loading"); + const [inviteDetails, setInviteDetails] = useState(null); + const [isAccepting, setIsAccepting] = useState(false); + const [isRejecting, setIsRejecting] = useState(false); + + const inviteToken = params?.inviteToken as string; + + useEffect(() => { + if (!inviteToken) { + setInviteStatus("invalid"); + return; + } + + // Always validate the token first, regardless of auth status + validateInviteToken(); + }, [inviteToken, token, user]); + + const validateInviteToken = async () => { + try { + const details = await apiValidateInviteToken(inviteToken, token || undefined); + + // For authenticated users, check if the invite is for their email + if (token && user && details.email !== user.name) { + setInviteStatus("permission_denied"); + return; + } + + setInviteStatus("valid"); + setInviteDetails(details); + } catch (error: any) { + console.error("Invite validation error:", error); + + if (error.message?.includes("expired")) { + setInviteStatus("expired"); + } else if (error.message?.includes("Invalid")) { + setInviteStatus("invalid"); + } else if (error.status === 404) { + setInviteStatus("invalid"); + } else { + setInviteStatus("invalid"); + } + } + }; + + const handleAcceptInvite = async () => { + if (!inviteToken || !token || !inviteDetails?.invite_id) return; + + setIsAccepting(true); + try { + const response = await apiAcceptInvite(inviteDetails.invite_id, inviteToken, token); + + toastSuccess("Invitation Accepted", response.message); + + // Refresh organizations list before redirecting + await updateOrgs(); + + // Redirect to dashboard or organization page + router.push("/dashboard"); + } catch (error: any) { + console.error("Accept invite error:", error); + toastError("Failed to Accept", error.message || "Could not accept invitation"); + } finally { + setIsAccepting(false); + } + }; + + const handleDeclineInvite = async () => { + if (!inviteToken || !token || !inviteDetails?.invite_id) return; + + setIsRejecting(true); + try { + const response = await apiDeclineInvite(inviteDetails.invite_id, inviteToken, token); + + toastSuccess("Invitation Declined", response.message); + + // Redirect to dashboard + router.push("/dashboard"); + } catch (error: any) { + console.error("Decline invite error:", error); + toastError("Failed to Decline", error.message || "Could not decline invitation"); + } finally { + setIsRejecting(false); + } + }; + + const getRoleColor = (role: string) => { + switch (role.toLowerCase()) { + case "admin": + return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"; + case "write": + return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"; + case "read": + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"; + default: + return "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"; + } + }; + + // Loading state + if (inviteStatus === "loading") { + return ( +
+ + + +

Validating invitation...

+
+
+
+ ); + } + + // Error states + if (inviteStatus === "invalid" || inviteStatus === "expired" || inviteStatus === "permission_denied") { + return ( +
+ + +
+ +
+ + {inviteStatus === "expired" && "Invitation Expired"} + {inviteStatus === "invalid" && "Invalid Invitation"} + {inviteStatus === "permission_denied" && "Access Denied"} + + + {inviteStatus === "expired" && "This invitation link has expired and is no longer valid."} + {inviteStatus === "invalid" && "This invitation link is invalid or has already been used."} + {inviteStatus === "permission_denied" && "This invitation is not for your account."} + +
+ + + +
+
+ ); + } + + // Feature not available + if (config?.organisation_invite_enabled === false) { + return ( +
+ + +
+ +
+ Not available + + The resource you were requesting for is not available due to configuration settings. + +
+ + + +
+
+ ); + } + + // Already accepted state + if (inviteStatus === "accepted") { + return ( +
+ + +
+ +
+ Already accepted + This invitation is already accepted. +
+ + + +
+
+ ); + } + + // Valid invite - show accept/decline UI + return ( +
+
+ {/* Header with logo */} +
+
+
+ Airborne Logo + Airborne Logo +
+
+
+ + + +
+ +
+ + Organization Invitation + + You’ve been invited to join an organization +
+ + + {/* Invitation Details */} +
+ + + +
+
+ Organization: + {inviteDetails?.organization} +
+
+ Role: + {inviteDetails?.role} +
+
+ Invited email: + {inviteDetails?.email} +
+
+
+
+ + {/* Role Description */} +
+

This role will give you:

+
    + {inviteDetails?.role === "admin" && ( + <> +
  • โ€ข Full administrative access
  • +
  • โ€ข Manage users and permissions
  • +
  • โ€ข Create and manage applications
  • +
  • โ€ข Access to all organization features
  • + + )} + {inviteDetails?.role === "write" && ( + <> +
  • โ€ข Create and edit applications
  • +
  • โ€ข Manage deployments and releases
  • +
  • โ€ข View analytics and reports
  • +
  • โ€ข Collaborate with team members
  • + + )} + {inviteDetails?.role === "read" && ( + <> +
  • โ€ข View applications and data
  • +
  • โ€ข Access analytics and reports
  • +
  • โ€ข Download deployment artifacts
  • +
  • โ€ข View team activity
  • + + )} +
+
+
+ + + + {/* Action buttons */} + {token && user ? ( + // Authenticated user - show accept/decline buttons +
+ + + +
+ ) : ( + // Non-authenticated user - show login/register options +
+
+

To accept this invitation, you need to sign in or create an account.

+
+
+ + + +
+
+ )} + + {/* Footer */} + {token && user && ( +
+

+ Not {user?.name}?{" "} + + Sign in with a different account + +

+
+ )} +
+
+
+
+ ); +} diff --git a/airborne_dashboard/app/invitation/not-found.tsx b/airborne_dashboard/app/invitation/not-found.tsx new file mode 100644 index 00000000..89afc528 --- /dev/null +++ b/airborne_dashboard/app/invitation/not-found.tsx @@ -0,0 +1,83 @@ +"use client"; + +import type React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { AlertCircle } from "lucide-react"; +import Link from "next/link"; +import Image from "next/image"; + +export default function InvitationNotFound() { + return ( +
+
+ {/* Header with logo */} +
+
+
+ Airborne Logo + Airborne Logo +
+
+
+ + + +
+ +
+ + Invitation Not Found + + + The invitation you’re looking for doesn’t exist or has been removed. + +
+ + +
+

This invitation link may be:

+
    +
  • โ€ข Invalid or malformed
  • +
  • โ€ข Already used or accepted
  • +
  • โ€ข Expired or revoked
  • +
  • โ€ข From an older system version
  • +
+
+ +
+ + + +
+ +
+

+ Need help?{" "} + + Contact support + +

+
+
+
+
+
+ ); +} diff --git a/airborne_dashboard/app/login/page.tsx b/airborne_dashboard/app/login/page.tsx index dd183126..c5b7129b 100644 --- a/airborne_dashboard/app/login/page.tsx +++ b/airborne_dashboard/app/login/page.tsx @@ -8,12 +8,13 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Checkbox } from "@/components/ui/checkbox"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Mail, Lock, Eye, EyeOff } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { apiFetch } from "@/lib/api"; import { useAppContext } from "@/providers/app-context"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; export default function LoginPage() { const [name, setName] = useState(""); // API expects "name" @@ -23,13 +24,46 @@ export default function LoginPage() { const [isLoading, setIsLoading] = useState(false); const { setToken, setUser, setOrg: setOrganisation, setApp: setApplication, token, config } = useAppContext(); const router = useRouter(); + const searchParams = useSearchParams(); + + // Get URL parameters for invite flow + const inviteToken = searchParams.get("invite_token"); + const redirectTo = searchParams.get("redirect_to"); + const inviteEmail = searchParams.get("email"); + + const isValidRedirectUrl = (url: string): boolean => { + // Only allow relative URLs or same-origin URLs + try { + const decoded = decodeURIComponent(url); + if (decoded.startsWith("/") && !decoded.startsWith("//")) { + return true; + } + const parsedUrl = new URL(decoded, window.location.origin); + return parsedUrl.origin === window.location.origin; + } catch { + return false; + } + }; + + useEffect(() => { + // Pre-fill email if coming from invitation + if (inviteEmail && !name) { + setName(decodeURIComponent(inviteEmail)); + } + }, [inviteEmail, name]); useEffect(() => { if (token && token != "") { console.log("Nav to dashboard effect", token); - router.replace("/dashboard"); + + // If we have a redirect URL (from invitation), go there instead of dashboard + if (redirectTo && isValidRedirectUrl(redirectTo)) { + router.replace(decodeURIComponent(redirectTo)); + } else { + router.replace("/dashboard"); + } } - }, [token, router]); + }, [token, router, redirectTo]); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); @@ -62,6 +96,15 @@ export default function LoginPage() { const data = await apiFetch<{ auth_url: string; state?: string }>("/users/oauth/url"); if (data?.auth_url) { localStorage.setItem("oauthAction", "login"); + + // Store invite parameters for after OAuth callback + if (inviteToken) { + localStorage.setItem("oauthInviteToken", inviteToken); + } + if (redirectTo && isValidRedirectUrl(redirectTo)) { + localStorage.setItem("oauthRedirectTo", redirectTo); + } + window.location.href = data.auth_url; } else { throw new Error("OAuth URL not available"); @@ -105,9 +148,28 @@ export default function LoginPage() { Sign in - Enter your credentials to access your account + + {inviteToken + ? "Sign in to accept your organization invitation" + : "Enter your credentials to access your account"} + + {/* Invitation context banner */} + {inviteToken && inviteEmail && ( +
+
+ +
+

Organization Invitation

+

+ Sign in with {decodeURIComponent(inviteEmail)} to + accept your invitation +

+
+
+
+ )} {/* Google Sign In */} {config?.google_signin_enabled && ( <> @@ -151,7 +213,10 @@ export default function LoginPage() { {/* Email/Password Form */}
- +
setName(e.target.value)} - className="pl-10" + className={`pl-10 ${inviteEmail ? "bg-muted text-muted-foreground cursor-not-allowed" : ""}`} required + readOnly={!!inviteEmail} + disabled={!!inviteEmail} /> + {inviteEmail && ( +
+ + + + + + +

This email is required for your organization invitation

+
+
+
+
+ )}
@@ -205,7 +286,14 @@ export default function LoginPage() { Remember me
- + Create account @@ -217,7 +305,14 @@ export default function LoginPage() {
Don't have an account? - + Sign up
diff --git a/airborne_dashboard/app/oauth/callback/page.tsx b/airborne_dashboard/app/oauth/callback/page.tsx index 7a1d9323..2c66eda4 100644 --- a/airborne_dashboard/app/oauth/callback/page.tsx +++ b/airborne_dashboard/app/oauth/callback/page.tsx @@ -31,7 +31,7 @@ type OAuthPatResponse = { export default function OAuthCallback() { const params = useSearchParams(); const router = useRouter(); - const { setToken, setUser, token, org, app, setOrg, setApp, loading } = useApp(); + const { setToken, setUser, token, org, app, loading } = useApp(); const processedCode = useRef(false); useEffect(() => { @@ -51,7 +51,7 @@ export default function OAuthCallback() { try { const endpoint = actionToEndpoint[oauthAction] ?? "/users/oauth/login"; - const res = await apiFetch( + let res = await apiFetch( endpoint, { method: "POST", @@ -72,18 +72,26 @@ export default function OAuthCallback() { ); return; } + res = res as OAuthUserResponse; - // normal login/signup flow - const userRes = res as OAuthUserResponse; - setToken(userRes.user_token?.access_token || ""); - setUser({ user_id: userRes.user_id, name: userRes.username, is_super_admin: userRes.is_super_admin }); - const organisation = userRes.organisations?.[0]?.name || ""; - const application = userRes.organisations?.[0]?.applications?.[0]?.application || ""; - setOrg(organisation); - setApp(application); - - window.location.replace("/dashboard"); - } catch (e) { + console.log("token exchange", oauthAction, res); + setToken(res.user_token?.access_token || ""); + setUser({ user_id: res.user_id, name: res.username, is_super_admin: res.is_super_admin }); // OAuth users will get name from API response + + // Check if we have invite-related redirect parameters stored + const storedRedirectTo = localStorage.getItem("oauthRedirectTo"); + + // Clean up stored parameters + localStorage.removeItem("oauthRedirectTo"); + localStorage.removeItem("oauthInviteToken"); + + // Redirect to invitation page if we came from an invitation, otherwise dashboard + if (storedRedirectTo) { + window.location.replace(storedRedirectTo); + } else { + window.location.replace("/dashboard"); + } + } catch (e: any) { console.log("Google Callback Error", e); router.replace("/login"); } diff --git a/airborne_dashboard/app/register/page.tsx b/airborne_dashboard/app/register/page.tsx index 47ad301a..e1baa46e 100644 --- a/airborne_dashboard/app/register/page.tsx +++ b/airborne_dashboard/app/register/page.tsx @@ -9,16 +9,19 @@ import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; -import { Mail, Lock, Eye, EyeOff, User, Check } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Mail, Lock, Eye, EyeOff, User, Check, UserPlus } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { apiFetch } from "@/lib/api"; import { useAppContext } from "@/providers/app-context"; import { toastWarning } from "@/hooks/use-toast"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; +import { validateInviteToken } from "@/lib/invitation"; export default function RegisterPage() { const router = useRouter(); + const searchParams = useSearchParams(); const [formData, setFormData] = useState({ firstName: "", lastName: "", @@ -33,14 +36,59 @@ export default function RegisterPage() { const [isLoading, setIsLoading] = useState(false); const { setToken, setUser, token, config } = useAppContext(); + // Invite-related state + const [inviteToken, setInviteToken] = useState(null); + const [redirectTo, setRedirectTo] = useState(null); + const [inviteInfo, setInviteInfo] = useState<{ + organization: string; + role: string; + email: string; + } | null>(null); + const [isLoadingInvite, setIsLoadingInvite] = useState(false); + useEffect(() => { if (token) router.replace("/dashboard"); - }, [token]); + + // Check for invite token in URL params + const inviteTokenParam = searchParams?.get("invite_token"); + const redirectToParam = searchParams?.get("redirect_to"); + + if (inviteTokenParam) { + setInviteToken(inviteTokenParam); + setRedirectTo(redirectToParam); + + // Fetch invite details to prefill the email + fetchInviteDetails(inviteTokenParam); + } + + async function fetchInviteDetails(token: string) { + setIsLoadingInvite(true); + try { + const details = await validateInviteToken(token); + setInviteInfo({ + organization: details.organization, + role: details.role, + email: details.email, + }); + + // Prefill the email in the form + setFormData((prev) => ({ + ...prev, + email: details.email, + })); + } catch (error) { + console.error("Failed to fetch invite details:", error); + // If invite is invalid, we can still let them register normally + } finally { + setIsLoadingInvite(false); + } + } + }, [token, searchParams]); const handleRegister = async (e: React.FormEvent) => { e.preventDefault(); if (formData.password !== formData.confirmPassword) { - toastWarning("Password Mismatch", "Passwords don't match"); + toastWarning("Password Mismatch", "Passwords don’t match"); return; } setIsLoading(true); @@ -53,7 +101,13 @@ export default function RegisterPage() { const token = res?.user_token?.access_token || ""; setToken(token); setUser({ user_id: res?.user_id, name: formData.email, is_super_admin: res?.is_super_admin || false }); - window.location.href = "/dashboard"; + + // If there’s an invite token, redirect to the invitation page after registration + if (inviteToken && redirectTo) { + window.location.href = redirectTo; + } else { + window.location.href = "/dashboard"; + } } catch (e: any) { console.log("User Register Error", e); // Error toast will be shown automatically by apiFetch @@ -169,10 +223,42 @@ export default function RegisterPage() { - Create account - Get started with your free account + + {inviteToken ? "Complete your invitation" : "Create account"} + + + {inviteToken + ? "Finish setting up your account to join the organization" + : "Get started with your free account"} + + {/* Invite Information */} + {inviteToken && ( + + + + {isLoadingInvite ? ( +
+

Loading invitation details...

+
+ ) : inviteInfo ? ( +
+

You’ve been invited to join:

+

+ {inviteInfo.organization} as {inviteInfo.role} +

+

Email: {inviteInfo.email}

+
+ ) : ( +
+

Processing invitation...

+

Complete registration to join the organization

+
+ )} +
+
+ )} {/* Google Sign Up */} {config?.google_signin_enabled && ( <> @@ -250,10 +336,11 @@ export default function RegisterPage() { updateFormData("email", e.target.value)} className="pl-10" + disabled={inviteInfo?.email === formData.email} required /> diff --git a/airborne_dashboard/components/application-access-modal.tsx b/airborne_dashboard/components/application-access-modal.tsx new file mode 100644 index 00000000..59bfab08 --- /dev/null +++ b/airborne_dashboard/components/application-access-modal.tsx @@ -0,0 +1,292 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Search, Users, Loader2 } from "lucide-react"; + +interface OrgUser { + id: string; + name: string; + email: string; + username: string; + roles?: string[]; +} + +interface ApplicationAccessInvite { + userId: string; + role: string; +} + +export interface ApplicationAccessModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (invites: ApplicationAccessInvite[]) => Promise; + orgUsers: OrgUser[]; + applicationName: string; + availableRoles?: string[]; + isLoading?: boolean; +} + +const DEFAULT_ROLES = ["read", "write", "admin"]; + +const ROLE_COLORS = { + admin: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + write: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", + read: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", +}; + +export function ApplicationAccessModal({ + isOpen, + onClose, + onSubmit, + orgUsers, + applicationName, + availableRoles = DEFAULT_ROLES, + isLoading = false, +}: ApplicationAccessModalProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedUsers, setSelectedUsers] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Reset state when modal opens/closes + useEffect(() => { + if (!isOpen) { + setSearchTerm(""); + setSelectedUsers({}); + } + }, [isOpen]); + + // Filter users based on search term + const filteredUsers = orgUsers.filter( + (user) => + user.name.toLowerCase().includes(searchTerm.toLowerCase()) || + user.email.toLowerCase().includes(searchTerm.toLowerCase()) || + user.username.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const handleUserToggle = (userId: string, checked: boolean) => { + if (checked) { + setSelectedUsers((prev) => ({ ...prev, [userId]: "read" })); // Default to read role + } else { + setSelectedUsers((prev) => { + const newSelected = { ...prev }; + delete newSelected[userId]; + return newSelected; + }); + } + }; + + const handleRoleChange = (userId: string, role: string) => { + setSelectedUsers((prev) => ({ ...prev, [userId]: role })); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + const allUsers = filteredUsers.reduce((acc, user) => ({ ...acc, [user.id]: "read" }), {}); + setSelectedUsers(allUsers); + } else { + setSelectedUsers({}); + } + }; + + const handleSubmit = async () => { + const invites = Object.entries(selectedUsers).map(([userId, role]) => ({ + userId, + role, + })); + + if (invites.length === 0) { + return; + } + + setIsSubmitting(true); + try { + await onSubmit(invites); + onClose(); + } finally { + setIsSubmitting(false); + } + }; + + const selectedCount = Object.keys(selectedUsers).length; + const isAllSelected = filteredUsers.length > 0 && filteredUsers.every((user) => selectedUsers[user.id]); + + const getRoleBadge = (role: string) => { + const colorClass = ROLE_COLORS[role as keyof typeof ROLE_COLORS] || "bg-gray-100 text-gray-800"; + return {role.charAt(0).toUpperCase() + role.slice(1)}; + }; + + return ( + !open && onClose()}> + + + + + Grant Application Access + + + Select users from your organization to grant access to {applicationName}. You can choose + different roles for each user. + + + +
+ {/* Search and Select All */} +
+
+
+ + setSearchTerm(e.target.value)} + className="pl-9" + /> +
+
+ +
+ + +
+
+ + {/* Users Table */} +
+ + + + Select + User + Email + Current Org Roles + App Role + + + + {isLoading ? ( + + +
+ + Loading users... +
+
+
+ ) : filteredUsers.length === 0 ? ( + + + {searchTerm + ? "No users match your search." + : orgUsers.length === 0 + ? "All organization users already have access to this application." + : "No users found in this organization."} + + + ) : ( + filteredUsers.map((user) => { + const isSelected = !!selectedUsers[user.id]; + const selectedRole = selectedUsers[user.id]; + + return ( + + + handleUserToggle(user.id, checked as boolean)} + /> + + +
{user.name}
+
@{user.username}
+
+ {user.email} + +
+ {user.roles?.map((role) => getRoleBadge(role)) || ( + No roles + )} +
+
+ + {isSelected ? ( + + ) : ( + - + )} + +
+ ); + }) + )} +
+
+
+ + {/* Selected Summary */} + {selectedCount > 0 && ( +
+
+ Selected {selectedCount} user{selectedCount !== 1 ? "s" : ""}: +
+
+ {Object.entries(selectedUsers).map(([userId, role]) => { + const user = orgUsers.find((u) => u.id === userId); + return ( +
+ {user?.name} + {getRoleBadge(role)} +
+ ); + })} +
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/airborne_dashboard/components/invite-management.tsx b/airborne_dashboard/components/invite-management.tsx new file mode 100644 index 00000000..931ffa41 --- /dev/null +++ b/airborne_dashboard/components/invite-management.tsx @@ -0,0 +1,452 @@ +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { + Search, + Mail, + MoreVertical, + Trash2, + RefreshCw, + Clock, + CheckCircle, + XCircle, + UserPlus, + Users, +} from "lucide-react"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { ListInvitesResponse } from "@/types/invitation"; +import { formatDistanceToNow, parseISO } from "date-fns"; +import { ApplicationAccessModal } from "./application-access-modal"; +import { OrganizationAccessModal } from "./organization-access-modal"; + +// Types for the new modal data +interface OrgUser { + id: string; + name: string; + email: string; + username: string; + roles?: string[]; +} + +interface Application { + id: string; + name: string; + description?: string; +} + +export interface InviteManagementProps { + data: ListInvitesResponse | undefined; + onRevokeInvite: (inviteId: string) => Promise; + onRefresh: () => void; + onSearchChange?: (search: string) => void; + onStatusFilterChange?: (status: string) => void; + onPageChange?: (page: number) => void; + searchTerm?: string; + statusFilter?: string; + isLoading?: boolean; + + // New props for the modals + showInviteButtons?: boolean; + entityType?: "organization" | "application"; + + // For Organization Access Modal + organizationName?: string; + applications?: Application[]; + onOrganizationInvite?: (invite: { + email: string; + orgRole: string; + applications: { name: string; level: string }[]; + }) => Promise; + + // For Application Access Modal + applicationName?: string; + orgUsers?: OrgUser[]; + availableRoles?: string[]; + onApplicationInvite?: (invites: { userId: string; role: string }[]) => Promise; + + // Loading states + isLoadingOrgUsers?: boolean; + isLoadingApplications?: boolean; +} + +const ROLE_COLORS = { + admin: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + write: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", + read: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", +}; + +const STATUS_CONFIG = { + pending: { + icon: Clock, + color: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + label: "Pending", + }, + accepted: { + icon: CheckCircle, + color: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + label: "Accepted", + }, + declined: { + icon: XCircle, + color: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + label: "Declined", + }, + expired: { + icon: XCircle, + color: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200", + label: "Expired", + }, +}; + +export function InviteManagement({ + data, + onRevokeInvite, + onRefresh, + onSearchChange, + onStatusFilterChange, + onPageChange, + searchTerm = "", + statusFilter = "all", + isLoading, + showInviteButtons = false, + entityType = "organization", + organizationName, + applications = [], + onOrganizationInvite, + applicationName, + orgUsers = [], + availableRoles, + onApplicationInvite, + isLoadingOrgUsers = false, + isLoadingApplications = false, +}: InviteManagementProps) { + const [isRevoking, setIsRevoking] = useState(null); + const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm); + + // Modal states + const [isOrgAccessModalOpen, setIsOrgAccessModalOpen] = useState(false); + const [isAppAccessModalOpen, setIsAppAccessModalOpen] = useState(false); + + const invites = data?.invites || []; + const pagination = data?.pagination; + + // Debounce the search term to avoid excessive API calls + const debouncedSearchTerm = useDebouncedValue(localSearchTerm, 300); + + // Effect to call the parent callback when debounced value changes + React.useEffect(() => { + if (debouncedSearchTerm !== searchTerm) { + onSearchChange?.(debouncedSearchTerm); + } + }, [debouncedSearchTerm, onSearchChange, searchTerm]); + + // Sync local state when prop changes (e.g., when resetting from parent) + React.useEffect(() => { + if (searchTerm !== localSearchTerm) { + setLocalSearchTerm(searchTerm); + } + }, [searchTerm]); + + const handleRevokeInvite = async (inviteId: string) => { + setIsRevoking(inviteId); + try { + await onRevokeInvite(inviteId); + } finally { + setIsRevoking(null); + } + }; + + // Modal handlers + const handleOrganizationInvite = async (invite: { + email: string; + orgRole: string; + applications: { name: string; level: string }[]; + }) => { + if (onOrganizationInvite) { + await onOrganizationInvite(invite); + onRefresh(); // Refresh the invites list + } + }; + + const handleApplicationInvite = async (invites: { userId: string; role: string }[]) => { + if (onApplicationInvite) { + await onApplicationInvite(invites); + onRefresh(); // Refresh the invites list + } + }; + + const getStatusBadge = (status: string) => { + const config = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.pending; + const Icon = config.icon; + + return ( + + + {config.label} + + ); + }; + + const getRoleBadge = (role: string) => { + const colorClass = ROLE_COLORS[role as keyof typeof ROLE_COLORS] || "bg-gray-100 text-gray-800"; + + return {role.charAt(0).toUpperCase() + role.slice(1)}; + }; + + return ( + + +
+
+ + + Invitations + +

+ {entityType === "organization" + ? "Manage organization invitations and their status" + : "Manage application invitations and their status"} +

+
+
+ {showInviteButtons && ( + <> + {entityType === "organization" ? ( + + ) : ( + + )} + + )} + +
+
+
+ + + {/* Filters */} +
+
+
+ + setLocalSearchTerm(e.target.value)} + className="max-w-sm pl-10" + /> +
+
+ + +
+ + {/* Invitations Table */} +
+ + + + Email + Role + Status + Invited + Actions + + + + {invites.length === 0 ? ( + + + {debouncedSearchTerm || statusFilter !== "all" + ? "No invitations match the current filters." + : "No invitations found."} + + + ) : ( + invites.map((invite) => ( + + {invite.email} + {getRoleBadge(invite.role)} + {getStatusBadge(invite.status)} + + {formatDistanceToNow(parseISO(invite.created_at), { addSuffix: true })} + + + {invite.status === "pending" && ( + + + + + + handleRevokeInvite(invite.id)} + className="text-destructive focus:text-destructive" + disabled={isRevoking === invite.id} + > + {isRevoking === invite.id ? ( + + ) : ( + + )} + Revoke Invitation + + + + )} + + + )) + )} + +
+
+ + {/* Summary and Pagination */} +
+
+
+ {pagination ? ( + pagination.total_items > 0 ? ( + <> + Showing {(pagination.current_page - 1) * pagination.per_page + 1}- + {Math.min(pagination.current_page * pagination.per_page, pagination.total_items)} of{" "} + {pagination.total_items} invitations + + ) : ( + <>No invitations + ) + ) : ( + <>Showing {invites.length} invitations + )} +
+
+ +
+
+ + {pagination && pagination.total_pages > 1 && ( +
+ + + + onPageChange?.(Math.max(1, pagination.current_page - 1))} + className={pagination.current_page <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + {Array.from({ length: pagination.total_pages }, (_, i) => i + 1) + .filter((page) => { + const current = pagination.current_page; + return ( + page === 1 || page === pagination.total_pages || (page >= current - 1 && page <= current + 1) + ); + }) + .map((page, index, pages) => ( + + {index > 0 && pages[index - 1] < page - 1 && ( + + + + )} + + onPageChange?.(page)} + isActive={page === pagination.current_page} + className="cursor-pointer" + > + {page} + + + + ))} + + onPageChange?.(Math.min(pagination.total_pages, pagination.current_page + 1))} + className={ + pagination.current_page >= pagination.total_pages + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + + + +
+ )} +
+
+ + {/* Organization Access Modal */} + setIsOrgAccessModalOpen(false)} + onSubmit={handleOrganizationInvite} + applications={applications} + organizationName={organizationName || ""} + isLoading={isLoadingApplications} + /> + + {/* Application Access Modal */} + setIsAppAccessModalOpen(false)} + onSubmit={handleApplicationInvite} + orgUsers={orgUsers} + applicationName={applicationName || ""} + availableRoles={availableRoles} + isLoading={isLoadingOrgUsers} + /> +
+ ); +} diff --git a/airborne_dashboard/components/organization-access-modal.tsx b/airborne_dashboard/components/organization-access-modal.tsx new file mode 100644 index 00000000..63efe1eb --- /dev/null +++ b/airborne_dashboard/components/organization-access-modal.tsx @@ -0,0 +1,344 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Mail, Building2, Loader2, CheckCircle2 } from "lucide-react"; +import { useAppContext } from "@/providers/app-context"; + +interface Application { + id: string; + name: string; + description?: string; +} + +interface ApplicationAccess { + name: string; + level: string; +} + +interface OrganizationAccessInvite { + email: string; + orgRole: string; + applications: ApplicationAccess[]; +} + +export interface OrganizationAccessModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (invite: OrganizationAccessInvite) => Promise; + applications: Application[]; + organizationName: string; + isLoading?: boolean; +} + +export function OrganizationAccessModal({ + isOpen, + onClose, + onSubmit, + applications, + organizationName, + isLoading = false, +}: OrganizationAccessModalProps) { + const { config } = useAppContext(); + const [email, setEmail] = useState(""); + const [selectedApplications, setSelectedApplications] = useState>(new Set()); + const [applicationRoles, setApplicationRoles] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [emailError, setEmailError] = useState(""); + + // Reset state when modal opens/closes + useEffect(() => { + if (!isOpen) { + setEmail(""); + setSelectedApplications(new Set()); + setApplicationRoles({}); + setEmailError(""); + } + }, [isOpen]); + + // Email validation + const validateEmail = (email: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!email) { + return "Email is required"; + } + if (!emailRegex.test(email)) { + return "Please enter a valid email address"; + } + return ""; + }; + + const handleEmailChange = (value: string) => { + setEmail(value); + const error = validateEmail(value); + setEmailError(error); + }; + + const handleApplicationToggle = (applicationId: string, checked: boolean) => { + const newSelected = new Set(selectedApplications); + const newRoles = { ...applicationRoles }; + + if (checked) { + newSelected.add(applicationId); + newRoles[applicationId] = "read"; // Default role + } else { + newSelected.delete(applicationId); + delete newRoles[applicationId]; + } + + setSelectedApplications(newSelected); + setApplicationRoles(newRoles); + }; + + const handleRoleChange = (applicationId: string, role: string) => { + setApplicationRoles((prev) => ({ ...prev, [applicationId]: role })); + }; + + const handleSelectAllApps = (checked: boolean) => { + if (checked) { + const allAppIds = applications.map((app) => app.id); + setSelectedApplications(new Set(allAppIds)); + + // Set default role for all applications + const defaultRoles: Record = {}; + allAppIds.forEach((appId) => { + defaultRoles[appId] = "read"; + }); + setApplicationRoles(defaultRoles); + } else { + setSelectedApplications(new Set()); + setApplicationRoles({}); + } + }; + + const handleSubmit = async () => { + const emailValidationError = validateEmail(email); + if (emailValidationError) { + setEmailError(emailValidationError); + return; + } + + const invite: OrganizationAccessInvite = { + email: email.trim(), + orgRole: "read", // Fixed to read as per requirements + applications: Array.from(selectedApplications).map((appId) => { + const app = applications.find((a) => a.id === appId); + return { + name: app?.name || appId, + level: applicationRoles[appId] || "read", + }; + }), + }; + + setIsSubmitting(true); + try { + await onSubmit(invite); + onClose(); + } catch (e: any) { + console.error(`Error sending ${config?.organisation_invite_enabled ? "invite" : "add"}:`, e); + setEmailError( + e?.message || `An error occurred while sending the ${config?.organisation_invite_enabled ? "invite" : "add"}.` + ); + } finally { + setIsSubmitting(false); + } + }; + + const selectedCount = selectedApplications.size; + const isAllSelected = applications.length > 0 && applications.every((app) => selectedApplications.has(app.id)); + const canSubmit = email && !emailError && !isSubmitting; + + return ( + !open && onClose()}> + + + + + {config?.organisation_invite_enabled ? "Invite" : "Add"} User to Organization + + + {config?.organisation_invite_enabled ? "Invite" : "Add"} a new user to {organizationName}{" "} + with read-only organization access. Select which applications they should have access to. You can change + access levels later. + + + +
+ {/* Email Input */} +
+ +
+ + handleEmailChange(e.target.value)} + className={`pl-9 ${emailError ? "border-red-500" : ""}`} + disabled={isSubmitting} + /> +
+ {emailError &&

{emailError}

} +
+ + {/* Organization Role Info */} +
+
+
+

Organization Role

+

+ The user will be granted read-only access to the organization +

+
+ Read +
+
+ + {/* Application Selection */} +
+
+ +
+ + +
+
+ + {/* Applications List */} +
+ {isLoading ? ( +
+
+ + Loading applications... +
+
+ ) : applications.length === 0 ? ( +
+ No applications available in this organization. +
+ ) : ( + + + + Select + Application + Description + Role + + + + {applications.map((app) => { + const isSelected = selectedApplications.has(app.id); + + return ( + + + handleApplicationToggle(app.id, checked as boolean)} + /> + + +
+
{app.name}
+ {isSelected && } +
+
+ + {app.description || "No description available"} + + + {isSelected ? ( + + ) : ( + - + )} + +
+ ); + })} +
+
+ )} +
+ + {/* Selected Applications Summary */} + {selectedCount > 0 && ( +
+
+ Selected {selectedCount} application{selectedCount !== 1 ? "s" : ""}: +
+
+ {Array.from(selectedApplications).map((appId) => { + const app = applications.find((a) => a.id === appId); + const role = applicationRoles[appId] || "read"; + return ( +
+ {app?.name} + - + + {role} + +
+ ); + })} +
+
+ )} +
+
+ + + + + +
+
+ ); +} diff --git a/airborne_dashboard/components/user-management.tsx b/airborne_dashboard/components/user-management.tsx index ee9229e3..f8f66de0 100644 --- a/airborne_dashboard/components/user-management.tsx +++ b/airborne_dashboard/components/user-management.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Search, UserPlus, MoreVertical, Trash2, ArrowRight, Crown } from "lucide-react"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { toastSuccess, toastError } from "@/hooks/use-toast"; export type AccessLevel = "owner" | "admin" | "write" | "read"; @@ -30,9 +31,11 @@ export interface UserManagementProps { onUpdateUser: (user: string, access: AccessLevel) => Promise; onRemoveUser: (user: string) => Promise; onTransferOwnership?: (user: string) => Promise; + onInviteCreated?: () => void; // Callback for when an invitation is created title?: string; description?: string; entityType?: "organisation" | "application"; + hideAddUserButton?: boolean; // New prop to hide the add user button } const ACCESS_LEVELS: { value: AccessLevel; label: string; color: string }[] = [ @@ -57,7 +60,7 @@ const getAccessLevelRoles = (level: AccessLevel): AccessLevel[] => { } }; -const canUpdateUsers = ( +export const canUpdateUsers = ( entityType: "organisation" | "application", orgAccess: string[], appAccess?: string[] @@ -86,9 +89,11 @@ export function UserManagement({ onUpdateUser, onRemoveUser, onTransferOwnership, + onInviteCreated, title = "User Management", description = "Manage users and their access levels", entityType = "organisation", + hideAddUserButton = false, }: UserManagementProps) { const [search, setSearch] = React.useState(""); const debouncedSearch = useDebouncedValue(search, 500); @@ -99,6 +104,7 @@ export function UserManagement({ const [userToRemove, setUserToRemove] = React.useState(null); const [isTransferDialogOpen, setIsTransferDialogOpen] = React.useState(false); const [userToTransfer, setUserToTransfer] = React.useState(null); + const [isInviting, setIsInviting] = React.useState(false); const canUpdate = canUpdateUsers(entityType, currentUserOrgAccess, currentUserAppAccess); const filteredUsers = useMemo(() => { @@ -108,13 +114,25 @@ export function UserManagement({ const handleAddUser = async () => { if (!newUser.trim()) return; + setIsInviting(true); try { await onAddUser(newUser.trim(), newUserAccess); setNewUser(""); setNewUserAccess("read"); setIsAddDialogOpen(false); - } catch (error) { + + // Show success toast + toastSuccess("Invitation Sent", `Successfully sent invitation to ${newUser.trim()} with ${newUserAccess} access`); + + // Notify parent component that an invitation was created + onInviteCreated?.(); + } catch (error: any) { console.error("Failed to add user:", error); + + // Show error toast + toastError("Failed to Send Invitation", error?.message || "Could not send invitation. Please try again."); + } finally { + setIsInviting(false); } }; @@ -181,7 +199,7 @@ export function UserManagement({ {title}

{description}

- {canUpdate && ( + {canUpdate && !hideAddUserButton && ( - diff --git a/airborne_dashboard/lib/invitation.ts b/airborne_dashboard/lib/invitation.ts new file mode 100644 index 00000000..235d5484 --- /dev/null +++ b/airborne_dashboard/lib/invitation.ts @@ -0,0 +1,156 @@ +import { + AcceptInviteResponse, + DeclineInviteResponse, + InviteDetails, + ListInvitesResponse, + ValidateInviteResponse, +} from "@/types/invitation"; +import { apiFetch } from "./api"; + +export async function validateInviteToken(token: string, _authToken?: string): Promise { + try { + const response = await apiFetch("/organisation/user/invite/validate", { + method: "POST", + body: { token }, + requireAuth: false, // Allow validation without authentication + showErrorToast: false, // We'll handle errors manually + }); + + if (!response.valid) { + throw new Error("Invalid invite token"); + } + + // Convert backend response to InviteDetails format + return { + invite_id: response.invite_id, + email: response.email, + organization: response.organization, + role: response.role, + status: response.status, + created_at: response.created_at, + inviter: response.inviter, + }; + } catch (error: any) { + // Handle specific error cases + if (error.message?.includes("Invalid or expired")) { + throw new Error("Invite token has expired or is invalid"); + } + + if (error.status === 404) { + throw new Error("Invalid invite token"); + } + + // Re-throw other errors + throw error; + } +} + +export async function acceptInvite(inviteId: string, token: string, authToken: string): Promise { + return apiFetch(`/organisations/user/invite/${inviteId}/accept`, { + method: "POST", + body: { token }, + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); +} + +export async function declineInvite( + inviteId: string, + token: string, + authToken: string +): Promise { + return apiFetch(`/organisations/user/invite/${inviteId}/decline`, { + method: "POST", + body: { token }, + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); +} + +export async function listInvites( + authToken: string, + orgId: string, + params?: { + search?: string; + status?: string; + page?: number; + per_page?: number; + } +): Promise { + return apiFetch("/organisations/user/invite/list", { + method: "GET", + query: params, + showErrorToast: false, + headers: { + Authorization: `Bearer ${authToken}`, + "x-organisation": orgId, + }, + }); +} + +// Revoke an invitation +export async function revokeInvite( + inviteId: string, + authToken: string, + orgId: string +): Promise<{ success: boolean; message: string }> { + return apiFetch<{ success: boolean; message: string }>(`/organisations/user/invite/${inviteId}/revoke`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "x-organisation": orgId, + }, + }); +} + +// Send organization invitation +export async function sendOrganizationInvite( + invite: { + email: string; + orgRole: string; + applications: string[]; + }, + authToken: string, + orgId: string +): Promise<{ success: boolean; message: string }> { + return apiFetch<{ success: boolean; message: string }>("/organisations/user/invite", { + method: "POST", + body: { + email: invite.email, + org_role: invite.orgRole, + applications: invite.applications, + }, + headers: { + Authorization: `Bearer ${authToken}`, + "x-organisation": orgId, + }, + }); +} + +// Send application access invitations +export async function sendApplicationInvites( + invites: Array<{ + userId: string; + role: string; + }>, + authToken: string, + orgId: string, + appId: string +): Promise<{ success: boolean; message: string; invited_count: number }> { + return apiFetch<{ success: boolean; message: string; invited_count: number }>("/applications/user/invite/grant", { + method: "POST", + body: { + user_invites: invites.map((invite) => ({ + user_id: invite.userId, + role: invite.role, + })), + }, + headers: { + Authorization: `Bearer ${authToken}`, + "x-organisation": orgId, + "x-application": appId, + }, + }); +} diff --git a/airborne_dashboard/next.config.mjs b/airborne_dashboard/next.config.mjs index 891adbfe..3bc9f482 100644 --- a/airborne_dashboard/next.config.mjs +++ b/airborne_dashboard/next.config.mjs @@ -14,7 +14,8 @@ const nextConfig = { destination: `https://airborne.juspay.in/analytics/:path*`, }, { - source: "/api/:api(releases|file|organisations|applications|users|packages|dashboard|token)/:path*", + source: + "/api/:api(releases|file|organisations|applications|users|packages|dashboard|token|organisation)/:path*", destination: `${backend}/api/:api/:path*`, }, { diff --git a/airborne_dashboard/providers/app-context.tsx b/airborne_dashboard/providers/app-context.tsx index 47991280..fe28e593 100644 --- a/airborne_dashboard/providers/app-context.tsx +++ b/airborne_dashboard/providers/app-context.tsx @@ -38,6 +38,7 @@ type AppContextType = { interface Configuration { google_signin_enabled: boolean; organisation_creation_disabled: boolean; + organisation_invite_enabled: boolean; } const AppContext = createContext(undefined); diff --git a/airborne_dashboard/types/invitation.ts b/airborne_dashboard/types/invitation.ts new file mode 100644 index 00000000..1cf825ac --- /dev/null +++ b/airborne_dashboard/types/invitation.ts @@ -0,0 +1,76 @@ +export interface InviteDetails { + invite_id: string; + email: string; + organization: string; + role: string; + status: string; + created_at: string; + inviter?: string; +} + +export interface ValidateInviteResponse { + invite_id: string; + valid: boolean; + email: string; + organization: string; + role: string; + status: string; + created_at: string; + inviter?: string; +} + +export interface AcceptInviteResponse { + success: boolean; + message: string; + organization: string; + role: string; + action: "Accepted" | "Declined"; +} + +export interface DeclineInviteResponse { + success: boolean; + message: string; + organization: string; + role: string; + action: "Accepted" | "Declined"; +} + +// List invitations for an organization +export interface ListInvitesResponse { + invites: InviteListItem[]; + pagination: { + current_page: number; + per_page: number; + total_items: number; + total_pages: number; + }; +} + +export interface InviteListItem { + id: string; + email: string; + role: string; + status: string; + created_at: string; +} + +// New types for the enhanced invitation system +export interface OrganizationInviteRequest { + email: string; + orgRole: string; + applications: string[]; +} + +export interface ApplicationInviteRequest { + userId: string; + role: string; +} + +export interface InviteResponse { + success: boolean; + message: string; +} + +export interface ApplicationInviteResponse extends InviteResponse { + invited_count: number; +} diff --git a/airborne_server/.env.example b/airborne_server/.env.example index 2d0c1597..091f43be 100644 --- a/airborne_server/.env.example +++ b/airborne_server/.env.example @@ -44,3 +44,10 @@ RUST_LOG=debug,info,error,actix_web=info,error LOG_FORMAT= SUPERPOSITION_MIGRATION_STRATEGY=PATCH MIGRATIONS_TO_RUN_ON_BOOT=db,superposition + +ENABLE_ORGANISATION_INVITE=false # Set to true to enable organisation invite flow (Requires SMTP setup) +MOCK_EMAIL_SENDING=true # Use Mailcatcher for local email testing (must be turned off in production) +SMTP_HOST=localhost +SMTP_PORT=587 +SMTP_USER=admin +SMTP_PASSWORD=admin diff --git a/airborne_server/Cargo.toml b/airborne_server/Cargo.toml index 3af42378..c45e72d8 100644 --- a/airborne_server/Cargo.toml +++ b/airborne_server/Cargo.toml @@ -34,14 +34,17 @@ http = "0.2.12" http-body = "1.0.1" jsonwebtoken = "9.3.1" keycloak = "=26.1.0" +lettre = { version = "=0.11.19", features = ["builder", "smtp-transport", "native-tls"] } log = "0.4.27" r2d2 = "=0.8.10" +rand = "0.8" reqwest = { version = "^0.12.5", features = ["blocking", "stream"] } rustls = { version = "0.23.5" } serde = { workspace = true } serde_json = { workspace = true } sha2 = "0.10" superposition_sdk = "0.91.1" +tera = "=1.20.1" thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/airborne_server/localhost:5433 b/airborne_server/localhost:5433 new file mode 100644 index 00000000..2f5155f4 Binary files /dev/null and b/airborne_server/localhost:5433 differ diff --git a/airborne_server/migrations/20251030123645_create_org_invites_table/down.sql b/airborne_server/migrations/20251030123645_create_org_invites_table/down.sql new file mode 100644 index 00000000..23cb3b3a --- /dev/null +++ b/airborne_server/migrations/20251030123645_create_org_invites_table/down.sql @@ -0,0 +1,5 @@ +-- This file should undo anything in `up.sql` +DROP TABLE IF EXISTS hyperotaserver.organisation_invites CASCADE; +DROP TYPE IF EXISTS hyperotaserver.invite_status CASCADE; +DROP TYPE IF EXISTS hyperotaserver.invite_role CASCADE; +DROP INDEX IF EXISTS organisation_invites_org_id_idx; \ No newline at end of file diff --git a/airborne_server/migrations/20251030123645_create_org_invites_table/up.sql b/airborne_server/migrations/20251030123645_create_org_invites_table/up.sql new file mode 100644 index 00000000..1b134b90 --- /dev/null +++ b/airborne_server/migrations/20251030123645_create_org_invites_table/up.sql @@ -0,0 +1,17 @@ +CREATE TYPE IF NOT EXISTS hyperotaserver.invite_status AS ENUM ('pending', 'accepted', 'declined', 'expired'); +CREATE TYPE IF NOT EXISTS hyperotaserver.invite_role AS ENUM ('admin', 'read', 'write'); + +CREATE TABLE IF NOT EXISTS hyperotaserver.organisation_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id TEXT NOT NULL, + applications JSONB NOT NULL DEFAULT '[]'::jsonb, + email TEXT NOT NULL, + role hyperotaserver.invite_role NOT NULL, + token TEXT NOT NULL, + status hyperotaserver.invite_status NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS organisation_invites_org_id_idx ON hyperotaserver.organisation_invites (org_id); + +CREATE UNIQUE INDEX IF NOT EXISTS organisation_invites_token_idx ON hyperotaserver.organisation_invites (token); diff --git a/airborne_server/src/dashboard/configuration.rs b/airborne_server/src/dashboard/configuration.rs index 038ba788..db1df000 100644 --- a/airborne_server/src/dashboard/configuration.rs +++ b/airborne_server/src/dashboard/configuration.rs @@ -29,6 +29,7 @@ pub fn add_routes() -> Scope { struct Configuration { google_signin_enabled: bool, organisation_creation_disabled: bool, + organisation_invite_enabled: bool, } #[get("")] @@ -39,6 +40,7 @@ async fn get_global_configurations( let config = Configuration { google_signin_enabled: state.env.enable_google_signin, organisation_creation_disabled: state.env.organisation_creation_disabled, + organisation_invite_enabled: state.env.enable_organisation_invite, }; Ok(Json(config)) diff --git a/airborne_server/src/main.rs b/airborne_server/src/main.rs index 000c9e0d..6278945e 100644 --- a/airborne_server/src/main.rs +++ b/airborne_server/src/main.rs @@ -38,6 +38,7 @@ use google_sheets4::{ yup_oauth2::{self, ServiceAccountAuthenticator}, Sheets, }; +use lettre::{transport::smtp::authentication::Credentials, SmtpTransport}; use log::info; use serde_json::json; use std::{ @@ -45,6 +46,7 @@ use std::{ sync::Arc, }; use superposition_sdk::config::Config as SrsConfig; +use tera::Tera; use tracing_actix_web::TracingLogger; use utils::{db, kms::decrypt_kms, transaction_manager::start_cleanup_job}; @@ -131,6 +133,16 @@ async fn main() -> std::io::Result<()> { .map(|s| s.trim().into()) .collect(); + let enable_organisation_invite = std::env::var("ENABLE_ORGANISATION_INVITE") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or_default(); + + let mock_email_sending = std::env::var("MOCK_EMAIL_SENDING") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or_default(); + //Need to check if this ENV exists on pod let uses_local_stack = std::env::var("AWS_ENDPOINT_URL"); let mut force_path_style = false; @@ -169,6 +181,40 @@ async fn main() -> std::io::Result<()> { None }; + let mailer = if mock_email_sending && enable_organisation_invite { + // Local testing with Mailcatcher / Mailhog + Some( + SmtpTransport::builder_dangerous("127.0.0.1") + .port(1025) + .build(), + ) + } else if enable_organisation_invite { + let smtp_host = std::env::var("SMTP_HOST").expect("SMTP_HOST must be set"); + let smtp_user = std::env::var("SMTP_USER").expect("SMTP_USER must be set"); + let smtp_password = std::env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD must be set"); + + let creds = Credentials::new(smtp_user, smtp_password); + + // STARTTLS for port 587 (submission) + Some( + SmtpTransport::starttls_relay(&smtp_host) + .expect("Failed to connect to SMTP server") + .credentials(creds) + .build(), + ) + } else { + None + }; + + let tera = if enable_organisation_invite { + Some(Tera::new("templates/**/*").map_err(|e| { + println!("Parsing error(s): {}", e); + std::io::Error::other("Template parsing error") + })?) + } else { + None + }; + // Initialize DB pool info!("Creating db pool"); let pool = db::establish_pool(&aws_kms_client).await; @@ -199,6 +245,7 @@ async fn main() -> std::io::Result<()> { default_configs: get_default_configs_from_file() .await .expect("Failed to load superposition default configs from file"), + enable_organisation_invite, }; // This is required for localStack @@ -250,6 +297,8 @@ async fn main() -> std::io::Result<()> { cf_client: aws_cloudfront_client, superposition_client, sheets_hub: hub, + mailer: mailer.map(Arc::new), + tera: tera.map(Arc::new), }); // Start the background cleanup job for transaction reconciliation @@ -303,6 +352,10 @@ async fn main() -> std::io::Result<()> { .service( web::scope("/dashboard/configuration").service(configuration::add_routes()), ) + .service( + web::scope("/organisation/user/invite") + .service(organisation::user::invite::add_public_routes()), + ) .service( web::scope("/organisations") .wrap(Auth) diff --git a/airborne_server/src/organisation/application/user.rs b/airborne_server/src/organisation/application/user.rs index 107e9017..a0ed8738 100644 --- a/airborne_server/src/organisation/application/user.rs +++ b/airborne_server/src/organisation/application/user.rs @@ -27,20 +27,23 @@ use crate::{ middleware::auth::{ validate_required_access, validate_user, Access, AuthResponse, ADMIN, READ, }, - organisation::application::{types::OrgAppError, user::types::*}, - types as airborne_types, - types::{ABError, AppState}, + organisation::{ + application::{types::OrgAppError, user::types::*}, + user::types::UserContext, + }, + types::{self as airborne_types, ABError, AppState}, utils::keycloak::{find_org_group, find_user_by_username, prepare_user_action}, }; use self::{ transaction::{ - add_user_with_transaction, get_user_current_role, remove_user_with_transaction, - update_user_with_transaction, + get_user_current_role, remove_user_with_transaction, update_user_with_transaction, }, utils::{check_role_hierarchy, is_last_admin_in_application, validate_access_level}, }; +pub use crate::organisation::application::user::transaction::add_user_with_transaction; + pub fn add_routes() -> Scope { Scope::new("") .service(application_list_users) @@ -137,7 +140,7 @@ async fn find_target_user( } /// Find an application and extract its context -async fn find_application( +pub async fn find_application( admin: &keycloak::KeycloakAdmin, realm: &str, org_name: &str, diff --git a/airborne_server/src/organisation/application/user/types.rs b/airborne_server/src/organisation/application/user/types.rs index b8229633..a6425368 100644 --- a/airborne_server/src/organisation/application/user/types.rs +++ b/airborne_server/src/organisation/application/user/types.rs @@ -49,13 +49,6 @@ pub struct UserInfo { pub roles: Vec, } -// Helper structs - -pub struct UserContext { - pub user_id: String, - pub username: String, -} - pub struct AppContext { pub org_name: String, pub app_name: String, diff --git a/airborne_server/src/organisation/user.rs b/airborne_server/src/organisation/user.rs index 03a5e984..fa3b7acb 100644 --- a/airborne_server/src/organisation/user.rs +++ b/airborne_server/src/organisation/user.rs @@ -12,8 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +pub mod invite; mod transaction; -mod types; +pub mod types; mod utils; use actix_web::{ @@ -27,27 +28,30 @@ use crate::{ middleware::auth::{ validate_required_access, validate_user, Access, AuthResponse, ADMIN, OWNER, READ, WRITE, }, - organisation::{types::OrgError, user::types::*}, - types as airborne_types, - types::{ABError, AppState}, + organisation::{ + application::{self, user::find_application}, + types::OrgError, + user::{transaction::add_user_with_transaction, types::*}, + }, + types::{self as airborne_types, ABError, AppState}, utils::keycloak::{find_org_group, find_user_by_username, prepare_user_action}, }; use self::{ transaction::{ - add_user_with_transaction, get_user_current_role, remove_user_with_transaction, - update_user_with_transaction, + get_user_current_role, remove_user_with_transaction, update_user_with_transaction, }, utils::{check_role_hierarchy, is_last_owner, validate_access_level}, }; pub fn add_routes() -> Scope { Scope::new("") - .service(organisation_list_users) .service(organisation_add_user) + .service(organisation_list_users) .service(organisation_update_user) .service(organisation_remove_user) .service(organisation_transfer_ownership) + .service(Scope::new("/invite").service(invite::add_routes())) } /// Get organization context and validate user permissions @@ -150,6 +154,13 @@ async fn organisation_add_user( body: Json, state: web::Data, ) -> airborne_types::Result> { + if state.env.enable_organisation_invite { + return Err(ABError::Forbidden( + "Organization invitations are enabled, kindly invite user to your organisation." + .to_string(), + )); + } + let body = body.into_inner(); // Get organization context and validate requester's permissions @@ -210,10 +221,40 @@ async fn organisation_add_user( body.user, organisation, role_name ); + for app_access in body.applications.unwrap_or_default() { + let app_context = find_application(&admin, &realm, &org_context.org_id, &app_access.name) + .await + .map_err(|e| { + ABError::NotFound(format!( + "Failed to find application {}: {}", + app_access.name, e + )) + })?; + + application::user::add_user_with_transaction( + &admin, + &realm, + &app_context, + &target_user, + match app_access.level { + AccessLvl::Admin => "admin", + AccessLvl::Write => "write", + AccessLvl::Read => "read", + }, + ) + .await + .map_err(|e| { + ABError::InternalServerError(format!( + "Failed to add user to application {}: {}", + app_access.name, e + )) + })?; + } + Ok(Json(UserOperationResponse { user: body.user, success: true, - operation: "add".to_string(), + operation: "add_user".to_string(), })) } diff --git a/airborne_server/src/organisation/user/invite.rs b/airborne_server/src/organisation/user/invite.rs new file mode 100644 index 00000000..2c308edf --- /dev/null +++ b/airborne_server/src/organisation/user/invite.rs @@ -0,0 +1,821 @@ +use actix_web::{ + get, post, + web::{self, Json, Path, Query}, + HttpMessage, HttpRequest, Scope, +}; +use chrono::Utc; +use diesel::{ + dsl::sql, + prelude::*, + sql_types::{Bool, Text}, +}; +use keycloak::KeycloakAdmin; +use log::{debug, info}; +use uuid::Uuid; + +use crate::{ + middleware::auth::{AuthResponse, ADMIN, WRITE}, + organisation::{ + application::{self, user::find_application}, + types::OrgError, + user::{ + find_organization, get_org_context, + invite::types::*, + transaction::add_user_with_transaction, + types::{UserOperationResponse, UserRequest}, + utils::validate_access_level, + AccessLvl, ApplicationAccess, OrgContext, UserContext, + }, + }, + run_blocking, + types::{self as airborne_types, ABError, AppState}, + utils::{ + db::{ + models::{InviteRole, InviteStatus, OrganisationInviteEntry}, + schema::hyperotaserver::organisation_invites::{self}, + DbPool, + }, + keycloak::{find_org_group, find_user_by_username, prepare_user_action}, + mail::Mail, + }, +}; + +mod types; + +pub fn add_routes() -> Scope { + Scope::new("") + .service(send_invitation) + .service(accept_invite) + .service(decline_invite) + .service(list_invites) + .service(revoke_invite) +} + +pub fn add_public_routes() -> Scope { + Scope::new("").service(validate_invite) +} + +/// Check if there's an existing pending invite for the same org and email +async fn find_existing_pending_invite( + pool: &DbPool, + org_id: &str, + email: &str, + role: &InviteRole, +) -> airborne_types::Result> { + let pool = pool.clone(); + let org_id = org_id.to_string(); + let email = email.to_string(); + let role = role.clone(); + + run_blocking!({ + let mut conn = pool.get()?; + + Ok(organisation_invites::table + .filter(organisation_invites::org_id.eq(&org_id)) + .filter(organisation_invites::email.eq(&email)) + .filter(organisation_invites::role.eq(&role)) + .filter( + organisation_invites::status.eq(crate::utils::db::models::InviteStatus::Pending), + ) + .first::(&mut conn) + .optional()?) + }) +} + +/// Update an existing invite's token and created_at timestamp +async fn update_existing_invite( + pool: &DbPool, + invite_id: Uuid, + new_token: String, + apps: Vec, +) -> airborne_types::Result { + let pool = pool.clone(); + + run_blocking!({ + let mut conn = pool.get()?; + + Ok(diesel::update(organisation_invites::table.find(invite_id)) + .set(( + organisation_invites::token.eq(new_token), + organisation_invites::created_at.eq(Utc::now()), + organisation_invites::applications + .eq(serde_json::to_value(apps).unwrap_or_default()), + )) + .get_result::(&mut conn)?) + }) +} + +/// Insert a new invite into the database +async fn create_new_invite( + pool: &DbPool, + org_id: String, + apps: Vec, + email: String, + role: InviteRole, + token: String, +) -> airborne_types::Result { + let pool = pool.clone(); + + run_blocking!({ + let mut conn = pool.get()?; + + let new_invite = OrganisationInviteEntry { + id: Uuid::new_v4(), + org_id, + email, + role, + token, + status: crate::utils::db::models::InviteStatus::Pending, + created_at: Utc::now(), + applications: serde_json::to_value(apps).unwrap_or_default(), + }; + + Ok(diesel::insert_into(organisation_invites::table) + .values(&new_invite) + .get_result::(&mut conn)?) + }) +} + +/// Generate a secure random token for invite links +fn generate_invite_token() -> String { + use rand::{distributions::Alphanumeric, Rng}; + + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect() +} + +/// Find invite by token +async fn find_invite_by_token( + pool: &DbPool, + token: &str, +) -> airborne_types::Result> { + let pool = pool.clone(); + let token = token.to_string(); + + run_blocking!({ + let mut conn = pool.get()?; + + Ok(organisation_invites::table + .filter(organisation_invites::token.eq(&token)) + .filter(organisation_invites::status.eq(InviteStatus::Pending)) + .first::(&mut conn) + .optional()?) + }) +} + +/// Update invite status to accepted +pub async fn update_invite_status( + pool: &DbPool, + invite_id: Uuid, + status: InviteStatus, +) -> airborne_types::Result { + let pool = pool.clone(); + + run_blocking!({ + let mut conn = pool.get()?; + + Ok(diesel::update(organisation_invites::table.find(invite_id)) + .set(organisation_invites::status.eq(status)) + .get_result::(&mut conn)?) + }) +} + +/// List invites for an organization with search and pagination +pub async fn list_organization_invites( + pool: &DbPool, + org_id: &str, + search_term: Option<&str>, + status_filter: Option, + page: u32, + per_page: u32, +) -> airborne_types::Result<(Vec, i64)> { + let pool = pool.clone(); + let org_id = org_id.to_string(); + let search_term = search_term.map(|s| s.to_string()); + + let status_filter = status_filter.to_owned(); + let search_pattern = if let Some(search) = &search_term { + debug!("Searching invites with term: {}", search); + Some(format!("%{}%", search.to_lowercase())) + } else { + None + }; + run_blocking!({ + let build_query = |org_id: String| { + let mut q = organisation_invites::table + .filter(organisation_invites::org_id.eq(org_id)) + .into_boxed(); + + if let Some(search) = &search_pattern { + q = + q.filter(organisation_invites::email.ilike(search.clone()).or( + sql::("LOWER(role::text) LIKE ").bind::(search.clone()), + )); + } + + if let Some(status) = status_filter.as_ref() { + q = q.filter(organisation_invites::status.eq(status)); + } + + q + }; + + let mut conn = pool.get()?; + + let total_count: i64 = build_query(org_id.clone()).count().get_result(&mut conn)?; + + let invites = build_query(org_id.clone()) + .order(organisation_invites::created_at.desc()) + .limit(per_page as i64) + .offset(((page - 1) * per_page) as i64) + .load::(&mut conn)?; + + Ok((invites, total_count)) + }) +} + +#[post("/{invite_id}/accept")] +pub async fn accept_invite( + req: HttpRequest, + body: Json, + state: web::Data, +) -> airborne_types::Result> { + check_invite_feature_enabled(&state.env)?; + let request = body.into_inner(); + + let auth = req + .extensions() + .get::() + .cloned() + .ok_or_else(|| ABError::Unauthorized("Missing auth context".to_string()))?; + + let user_email = auth.username.clone(); + + let (admin, realm) = prepare_user_action(&req, state.clone()) + .await + .map_err(|e| ABError::InternalServerError(e.to_string()))?; + + rsvp_invitation( + admin, + realm, + &state.db_pool, + &request.token, + &user_email, + InviteAction::Accepted, + ) + .await + .map(Json) +} + +#[post("/{invite_id}/decline")] +pub async fn decline_invite( + req: HttpRequest, + body: Json, + state: web::Data, +) -> airborne_types::Result> { + check_invite_feature_enabled(&state.env)?; + let request = body.into_inner(); + + let auth = req + .extensions() + .get::() + .cloned() + .ok_or_else(|| ABError::Unauthorized("Missing auth context".to_string()))?; + + let user_email = auth.username.clone(); + + let (admin, realm) = prepare_user_action(&req, state.clone()) + .await + .map_err(|e| ABError::InternalServerError(e.to_string()))?; + + rsvp_invitation( + admin, + realm, + &state.db_pool, + &request.token, + &user_email, + InviteAction::Declined, + ) + .await + .map(Json) +} + +pub async fn rsvp_invitation( + admin: KeycloakAdmin, + realm: String, + db_pool: &DbPool, + token: &str, + user_email: &str, + action: InviteAction, +) -> airborne_types::Result { + debug!("Processing invite RSVP"); + + let invite = find_invite_by_token(db_pool, token) + .await? + .ok_or_else(|| ABError::BadRequest("Invalid or expired invite token".to_string()))?; + + if invite.email != user_email { + return Err(ABError::Unauthorized( + "Invite token does not match authenticated user".to_string(), + )); + } + + let invite_age = Utc::now().signed_duration_since(invite.created_at); + if invite_age.num_days() > 7 { + return Err(ABError::BadRequest("Invite token has expired".to_string())); + } + + debug!( + "Found valid invite for user to join org {} with role {:?}", + invite.org_id, invite.role + ); + + let target_user = find_user_by_username(&admin, &realm, user_email) + .await + .map_err(|e| ABError::InternalServerError(format!("Keycloak error: {}", e)))? + .ok_or_else(|| ABError::NotFound(user_email.to_string()))?; + + let target_user_id = target_user + .id + .as_ref() + .ok_or_else(|| ABError::InternalServerError("User has no ID".to_string()))? + .to_string(); + + let username = target_user + .username + .as_ref() + .ok_or_else(|| ABError::InternalServerError("User has no username".to_string()))? + .to_string(); + + let user_context = UserContext { + user_id: target_user_id, + username, + }; + + let org_group = find_org_group(&admin, &realm, &invite.org_id) + .await + .map_err(|e| ABError::InternalServerError(format!("Keycloak error: {}", e)))? + .ok_or_else(|| ABError::NotFound(invite.org_id.clone()))?; + + let org_group_id = org_group + .id + .as_ref() + .ok_or_else(|| ABError::InternalServerError("Group has no ID".to_string()))? + .to_string(); + + let org_context = OrgContext { + org_id: invite.org_id.clone(), + group_id: org_group_id, + }; + + let role_name = match invite.role { + InviteRole::Admin => "admin", + InviteRole::Write => "write", + InviteRole::Read => "read", + }; + + if action == InviteAction::Accepted { + debug!( + "Adding user to organization {} with role {}", + invite.org_id, role_name + ); + update_invite_status(db_pool, invite.id, InviteStatus::Accepted).await?; + + if let Err(e) = + add_user_with_transaction(&admin, &realm, &org_context, &user_context, role_name).await + { + // Revert the invite status back to pending on failure + update_invite_status(db_pool, invite.id, InviteStatus::Pending).await?; + info!( + "Failed to add user to organization {}: {}, reverting invitation status", + invite.org_id, e + ); + return Err(ABError::InternalServerError(e.to_string())); + } + + let req_applications: Vec = invite + .applications + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|app_value| serde_json::from_value(app_value.clone()).ok()) + .collect(); + + for app_access in req_applications { + let app_context = + find_application(&admin, &realm, &org_context.org_id, &app_access.name) + .await + .map_err(|e| { + ABError::NotFound(format!( + "Failed to find application {}: {}", + app_access.name, e + )) + })?; + + application::user::add_user_with_transaction( + &admin, + &realm, + &app_context, + &user_context, + match app_access.level { + AccessLvl::Admin => "admin", + AccessLvl::Write => "write", + AccessLvl::Read => "read", + }, + ) + .await + .map_err(|e| { + ABError::InternalServerError(format!( + "Failed to add user to application {}: {}", + app_access.name, e + )) + })?; + } + + info!("User accepted invite to organization {}", invite.org_id); + } else { + debug!("User is declining invite to organization {}", invite.org_id); + update_invite_status(db_pool, invite.id, InviteStatus::Declined).await?; + + info!("User declined invite to organization {}", invite.org_id); + } + + Ok(InviteRSVPResponse { + success: true, + message: if action == InviteAction::Accepted { + format!( + "Successfully joined organization {} as {}", + invite.org_id, role_name + ) + } else { + format!("Declined invitation to organization {}", invite.org_id) + }, + organization: invite.org_id, + role: role_name.to_string(), + action, + }) +} + +#[post("/validate")] +pub async fn validate_invite( + body: Json, + state: web::Data, +) -> airborne_types::Result> { + check_invite_feature_enabled(&state.env)?; + + let request = body.into_inner(); + + let invite = find_invite_by_token(&state.db_pool, &request.token) + .await? + .ok_or_else(|| ABError::NotFound("Invalid or expired invite token".to_string()))?; + + let invite_age = Utc::now().signed_duration_since(invite.created_at); + if invite_age.num_days() > 7 { + return Err(ABError::BadRequest("Invite token has expired".to_string())); + } + + let role_str = match invite.role { + InviteRole::Admin => "admin", + InviteRole::Write => "write", + InviteRole::Read => "read", + }; + + let status_str = match invite.status { + InviteStatus::Pending => "pending", + InviteStatus::Accepted => "accepted", + InviteStatus::Declined => "declined", + InviteStatus::Expired => "expired", + }; + + Ok(Json(ValidateInviteResponse { + invite_id: invite.id.to_string(), + valid: true, + email: invite.email, + organization: invite.org_id, + role: role_str.to_string(), + status: status_str.to_string(), + created_at: invite.created_at.to_rfc3339(), + inviter: None, + })) +} + +#[get("/list")] +pub async fn list_invites( + req: HttpRequest, + query: Query, + state: web::Data, +) -> airborne_types::Result> { + check_invite_feature_enabled(&state.env)?; + + // Get organization context and validate requester's permissions + let (organisation, _) = get_org_context(&req, WRITE, "list invites") + .await + .map_err(|e| ABError::InternalServerError(e.to_string()))?; + + let query = query.into_inner(); + + // Validate and set pagination defaults + let page = query.page.unwrap_or(1).max(1); + let per_page = query.per_page.unwrap_or(10).clamp(1, 100); + + // Parse status filter if provided + let status_filter = if let Some(status_str) = &query.status { + match status_str.to_lowercase().as_str() { + "pending" => Some(InviteStatus::Pending), + "accepted" => Some(InviteStatus::Accepted), + "declined" => Some(InviteStatus::Declined), + "expired" => Some(InviteStatus::Expired), + _ => { + return Err(ABError::BadRequest(format!( + "Invalid status filter: {}", + status_str + ))) + } + } + } else { + None + }; + + debug!( + "Listing invites for org {} with search: {:?}, status: {:?}, page: {}, per_page: {}", + organisation, query.search, query.status, page, per_page + ); + + // Get invites from database + let (invites, total_count) = list_organization_invites( + &state.db_pool, + &organisation, + query.search.as_deref(), + status_filter, + page, + per_page, + ) + .await?; + + // Convert to response format + let invite_items: Vec = invites + .into_iter() + .map(|invite| { + let role_str = match invite.role { + InviteRole::Admin => "admin", + InviteRole::Write => "write", + InviteRole::Read => "read", + }; + + let status_str = match invite.status { + InviteStatus::Pending => "pending", + InviteStatus::Accepted => "accepted", + InviteStatus::Declined => "declined", + InviteStatus::Expired => "expired", + }; + + InviteListItem { + id: invite.id.to_string(), + email: invite.email, + role: role_str.to_string(), + status: status_str.to_string(), + created_at: invite.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + } + }) + .collect(); + + // Calculate pagination info + let total_pages = ((total_count as f64) / (per_page as f64)).ceil() as u32; + + let pagination = PaginationInfo { + current_page: page, + per_page, + total_items: total_count, + total_pages, + }; + + debug!( + "Found {} invites for organization {} (page {} of {})", + invite_items.len(), + organisation, + page, + total_pages + ); + + Ok(Json(ListInvitesResponse { + invites: invite_items, + pagination, + })) +} + +#[post("/{invite_id}/revoke")] +pub async fn revoke_invite( + req: HttpRequest, + invite_id: Path, + state: web::Data, +) -> airborne_types::Result> { + check_invite_feature_enabled(&state.env)?; + + let (organisation, _) = get_org_context(&req, WRITE, "add user") + .await + .map_err(|e| ABError::InternalServerError(e.to_string()))?; + + let pool = state.db_pool.clone(); + let invite_id = Uuid::parse_str(&invite_id.into_inner()) + .map_err(|e| ABError::BadRequest(format!("Invalid invite ID: {}", e)))?; + let invite = run_blocking!({ + let mut conn = pool.get()?; + + let invite = organisation_invites::table + .find(invite_id) + .first::(&mut conn)?; + + if invite.org_id != organisation { + return Err(ABError::Forbidden( + "You do not have permission to revoke this invite".to_string(), + )); + } + + Ok(diesel::update(organisation_invites::table.find(invite_id)) + .set(organisation_invites::status.eq(InviteStatus::Expired)) + .get_result::(&mut conn)?) + })?; + + Ok(Json(invite)) +} + +#[post("")] +async fn send_invitation( + req: HttpRequest, + body: Json, + state: web::Data, +) -> airborne_types::Result> { + let body = body.into_inner(); + + check_invite_feature_enabled(&state.env)?; + + let mailer = state.mailer.as_ref().ok_or_else(|| { + ABError::InternalServerError( + "Mailer not configured, check your SMTP configuration".to_string(), + ) + })?; + + let tera = state.tera.as_ref().ok_or_else(|| { + ABError::InternalServerError("Template engine not configured".to_string()) + })?; + + // Get organization context and validate requester's permissions + let (organisation, auth) = get_org_context(&req, WRITE, "add user").await?; + + // Prepare Keycloak admin client + let (admin, realm) = prepare_user_action(&req, state.clone()) + .await + .map_err(|e| ABError::InternalServerError(e.to_string()))?; + + // Validate access level + let (role_name, role_level) = validate_access_level(&body.access.as_str())?; + + // Additional permission check for admin/owner assignments + if role_level >= ADMIN.access { + if let Some(org_access) = &auth.organisation { + if org_access.level < ADMIN.access { + return Err(OrgError::PermissionDenied( + "Admin permission required to assign admin or owner roles".into(), + ) + .into()); + } + } else { + return Err(ABError::Forbidden("No organization access".to_string())); + } + } + + let _ = find_organization(&admin, &realm, &organisation).await?; + + let invite_role = body.access.to_invite_role(); + + // Check if there's an existing pending invite for same org, email, and role + match find_existing_pending_invite(&state.db_pool, &organisation, &body.user, &invite_role) + .await + { + Ok(Some(existing_invite)) => { + // Update existing invite with new token and timestamp + let new_token = generate_invite_token(); + match update_existing_invite( + &state.db_pool, + existing_invite.id, + new_token.clone(), + body.applications.unwrap_or_default(), + ) + .await + { + Ok(_updated_invite) => { + info!( + "Updated existing invite for {} in org {}", + &body.user, &organisation + ); + + let invitation_url = + format!("{}/invitation/{}", &state.env.public_url, &new_token); + + // Send email with updated invite + let mut context = tera::Context::new(); + context.insert("name", &body.user); + context.insert("organization", &organisation); + context.insert("role", &role_name); + context.insert("invitation_url", &invitation_url); + + let mail = Mail::new( + mailer, + tera, + context, + body.user.clone(), + "You're invited to join an Airborne organization".to_string(), + "org_invitation.txt".to_string(), + Some("org_invitation.html".to_string()), + ); + + mail.send().await?; + + Ok(Json(UserOperationResponse { + user: body.user, + success: true, + operation: "invite_updated".to_string(), + })) + } + Err(e) => Err(e), + } + } + Ok(None) => { + // No existing invite - create new one + let new_token = generate_invite_token(); + match create_new_invite( + &state.db_pool, + organisation.clone(), + body.applications.unwrap_or_default(), + body.user.clone(), + invite_role, + new_token.clone(), + ) + .await + { + Ok(_new_invite) => { + info!( + "Created new invite for {} in org {}", + &body.user, &organisation + ); + + let invitation_url = + format!("{}/invitation/{}", &state.env.public_url, &new_token); + + // Send email with new invite + let mut context = tera::Context::new(); + context.insert("name", &body.user); + context.insert("organization", &organisation); + context.insert("role", &role_name); + context.insert("invitation_url", &invitation_url); + + let mail = Mail::new( + mailer, + tera, + context, + body.user.clone(), + "You're invited to join an Airborne organization".to_string(), + "org_invitation.txt".to_string(), + Some("org_invitation.html".to_string()), + ); + + if let Err(e) = mail.send().await { + info!("Failed to send invitation email to {}: {}", &body.user, e); + // Remove this invite from db if email sending fails + let _ = run_blocking!({ + let mut conn = state.db_pool.get()?; + + Ok( + diesel::delete(organisation_invites::table.find(_new_invite.id)) + .execute(&mut conn)?, + ) + }); + return Err(e); + } + + Ok(Json(UserOperationResponse { + user: body.user, + success: true, + operation: "invite_created".to_string(), + })) + } + Err(e) => Err(e), + } + } + Err(e) => Err(e), + } +} + +pub fn check_invite_feature_enabled( + env: &airborne_types::Environment, +) -> airborne_types::Result<()> { + if !env.enable_organisation_invite { + return Err(ABError::Forbidden( + "Organization invitations are disabled".to_string(), + )); + } + Ok(()) +} diff --git a/airborne_server/src/organisation/user/invite/types.rs b/airborne_server/src/organisation/user/invite/types.rs new file mode 100644 index 00000000..3f8d717e --- /dev/null +++ b/airborne_server/src/organisation/user/invite/types.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub struct AcceptInviteRequest { + pub token: String, +} + +pub type DeclineInviteRequest = AcceptInviteRequest; + +#[derive(Serialize)] +pub struct InviteRSVPResponse { + pub success: bool, + pub message: String, + pub organization: String, + pub role: String, + pub action: InviteAction, +} + +#[derive(Serialize, PartialEq, Debug)] +pub enum InviteAction { + Accepted, + Declined, +} + +#[derive(Deserialize)] +pub struct ListInvitesQuery { + pub search: Option, + pub status: Option, + pub page: Option, + pub per_page: Option, +} + +#[derive(Serialize)] +pub struct InviteListItem { + pub id: String, + pub email: String, + pub role: String, + pub status: String, + pub created_at: String, +} + +#[derive(Serialize)] +pub struct ListInvitesResponse { + pub invites: Vec, + pub pagination: PaginationInfo, +} + +#[derive(Serialize)] +pub struct PaginationInfo { + pub current_page: u32, + pub per_page: u32, + pub total_items: i64, + pub total_pages: u32, +} + +#[derive(Deserialize)] +pub struct ValidateInviteRequest { + pub token: String, +} + +#[derive(Serialize)] +pub struct ValidateInviteResponse { + pub invite_id: String, + pub valid: bool, + pub email: String, + pub organization: String, + pub role: String, + pub status: String, + pub created_at: String, + pub inviter: Option, +} diff --git a/airborne_server/src/organisation/user/types.rs b/airborne_server/src/organisation/user/types.rs index 0f2dfbeb..60a5f937 100644 --- a/airborne_server/src/organisation/user/types.rs +++ b/airborne_server/src/organisation/user/types.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; -#[derive(Deserialize, Debug)] +use crate::utils::db::models::InviteRole; + +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "lowercase")] pub enum AccessLvl { Admin, @@ -16,11 +18,27 @@ impl AccessLvl { Self::Read => "read".to_string(), } } + + pub fn to_invite_role(&self) -> InviteRole { + match self { + Self::Admin => InviteRole::Admin, + Self::Write => InviteRole::Write, + Self::Read => InviteRole::Read, + } + } +} + +#[derive(Deserialize, Serialize)] +pub struct ApplicationAccess { + pub name: String, + pub level: AccessLvl, } + #[derive(Deserialize)] pub struct UserRequest { pub user: String, pub access: AccessLvl, + pub applications: Option>, } #[derive(Deserialize)] diff --git a/airborne_server/src/types.rs b/airborne_server/src/types.rs index 161acc2d..9db8a887 100644 --- a/airborne_server/src/types.rs +++ b/airborne_server/src/types.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::sync::Arc; + use actix_web::{ body::BoxBody, http::{header::HeaderMap, StatusCode}, @@ -21,9 +23,11 @@ use diesel::result::{DatabaseErrorKind, Error as DieselErr}; use google_sheets4::{hyper_rustls, hyper_util, Sheets}; use http::{HeaderName, HeaderValue}; use keycloak::KeycloakError; +use lettre::SmtpTransport; use log::error; use serde::{Deserialize, Deserializer, Serialize}; use superposition_sdk::Client; +use tera::Tera; use thiserror::Error; use crate::{ @@ -41,6 +45,8 @@ pub struct AppState { pub sheets_hub: Option< Sheets>, >, + pub mailer: Option>, + pub tera: Option>, } #[derive(Clone, Debug)] @@ -59,6 +65,7 @@ pub struct Environment { pub google_spreadsheet_id: String, pub cloudfront_distribution_id: String, pub default_configs: Vec, + pub enable_organisation_invite: bool, } pub trait AppError: std::error::Error + Send + Sync + 'static { fn code(&self) -> &'static str; diff --git a/airborne_server/src/utils.rs b/airborne_server/src/utils.rs index 5beb7528..1be889cc 100644 --- a/airborne_server/src/utils.rs +++ b/airborne_server/src/utils.rs @@ -18,6 +18,7 @@ pub mod document; pub mod encryption; pub mod keycloak; pub mod kms; +pub mod mail; pub mod migrations; pub mod s3; pub mod semver; diff --git a/airborne_server/src/utils/db/models.rs b/airborne_server/src/utils/db/models.rs index 150d7eba..8895f385 100644 --- a/airborne_server/src/utils/db/models.rs +++ b/airborne_server/src/utils/db/models.rs @@ -1,14 +1,85 @@ use chrono::{DateTime, Utc}; -use diesel::deserialize::Queryable; +use diesel::deserialize::{FromSql, Queryable}; +use diesel::pg::{Pg, PgValue}; use diesel::prelude::*; +use diesel::serialize::{Output, ToSql}; +use diesel::{AsExpression, FromSqlRow}; use serde::{Deserialize, Serialize}; +use std::io::Write; use crate::utils::db::schema::hyperotaserver::{ - builds, cleanup_outbox, configs, files, packages, packages_v2, release_views, releases, - user_credentials, workspace_names, + builds, cleanup_outbox, configs, files, organisation_invites, packages, packages_v2, + release_views, releases, user_credentials, workspace_names, }; use crate::utils::semver::SemVer; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = crate::utils::db::schema::hyperotaserver::sql_types::InviteRole)] +pub enum InviteRole { + Admin, + Read, + Write, +} + +impl ToSql for InviteRole { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> diesel::serialize::Result { + let value = match self { + InviteRole::Admin => "admin", + InviteRole::Read => "read", + InviteRole::Write => "write", + }; + out.write_all(value.as_bytes())?; + Ok(diesel::serialize::IsNull::No) + } +} + +impl FromSql for InviteRole { + fn from_sql(bytes: PgValue<'_>) -> diesel::deserialize::Result { + match std::str::from_utf8(bytes.as_bytes())? { + "admin" => Ok(InviteRole::Admin), + "read" => Ok(InviteRole::Read), + "write" => Ok(InviteRole::Write), + _ => Err("Unrecognized enum variant".into()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = crate::utils::db::schema::hyperotaserver::sql_types::InviteStatus)] +pub enum InviteStatus { + Pending, + Accepted, + Declined, + Expired, +} + +impl ToSql for InviteStatus { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> diesel::serialize::Result { + let value = match self { + InviteStatus::Pending => "pending", + InviteStatus::Accepted => "accepted", + InviteStatus::Declined => "declined", + InviteStatus::Expired => "expired", + }; + out.write_all(value.as_bytes())?; + Ok(diesel::serialize::IsNull::No) + } +} + +impl FromSql + for InviteStatus +{ + fn from_sql(bytes: PgValue<'_>) -> diesel::deserialize::Result { + match std::str::from_utf8(bytes.as_bytes())? { + "pending" => Ok(InviteStatus::Pending), + "accepted" => Ok(InviteStatus::Accepted), + "declined" => Ok(InviteStatus::Declined), + "expired" => Ok(InviteStatus::Expired), + _ => Err("Unrecognized enum variant".into()), + } + } +} + #[derive(Debug, Deserialize, Serialize, Clone)] pub struct File { pub url: String, @@ -192,3 +263,16 @@ pub struct UserCredentialsEntry { pub application: String, pub created_at: DateTime, } + +#[derive(Queryable, Insertable, Debug, Selectable, Serialize)] +#[diesel(table_name = organisation_invites)] +pub struct OrganisationInviteEntry { + pub id: uuid::Uuid, + pub org_id: String, + pub applications: serde_json::Value, + pub email: String, + pub role: InviteRole, + pub token: String, + pub status: InviteStatus, + pub created_at: DateTime, +} diff --git a/airborne_server/src/utils/db/schema.rs b/airborne_server/src/utils/db/schema.rs index 77374026..0acce511 100644 --- a/airborne_server/src/utils/db/schema.rs +++ b/airborne_server/src/utils/db/schema.rs @@ -1,6 +1,40 @@ // @generated automatically by Diesel CLI. pub mod hyperotaserver { + pub mod sql_types { + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "invite_role", schema = "hyperotaserver"))] + pub struct InviteRole; + + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "invite_status", schema = "hyperotaserver"))] + pub struct InviteStatus; + } + + diesel::table! { + hyperotaserver.application_settings (id) { + id -> Uuid, + version -> Int4, + org_id -> Text, + app_id -> Text, + maven_namespace -> Text, + maven_artifact_id -> Text, + maven_group_id -> Text, + created_at -> Timestamptz, + } + } + + diesel::table! { + hyperotaserver.builds (id) { + id -> Uuid, + build_version -> Text, + organisation -> Text, + application -> Text, + release_id -> Text, + created_at -> Timestamptz, + } + } + diesel::table! { hyperotaserver.cleanup_outbox (transaction_id) { transaction_id -> Text, @@ -44,6 +78,23 @@ pub mod hyperotaserver { } } + diesel::table! { + use diesel::sql_types::*; + use super::sql_types::InviteRole; + use super::sql_types::InviteStatus; + + hyperotaserver.organisation_invites (id) { + id -> Uuid, + org_id -> Text, + applications -> Jsonb, + email -> Text, + role -> InviteRole, + token -> Text, + status -> InviteStatus, + created_at -> Timestamptz, + } + } + diesel::table! { hyperotaserver.packages (id) { id -> Uuid, @@ -115,22 +166,13 @@ pub mod hyperotaserver { } } - diesel::table! { - hyperotaserver.builds (id) { - id -> Uuid, - build_version -> Text, - organisation -> Text, - application -> Text, - release_id -> Text, - created_at -> Timestamptz, - } - } - diesel::allow_tables_to_appear_in_same_query!( - cleanup_outbox, + application_settings, builds, + cleanup_outbox, configs, files, + organisation_invites, packages, packages_v2, release_views, diff --git a/airborne_server/src/utils/mail.rs b/airborne_server/src/utils/mail.rs new file mode 100644 index 00000000..e6f3faf8 --- /dev/null +++ b/airborne_server/src/utils/mail.rs @@ -0,0 +1,101 @@ +use std::sync::Arc; + +use lettre::{message::Mailbox, Address, Message, SmtpTransport, Transport}; +use log::error; +use tera::Tera; + +use crate::{run_blocking, types::ABError}; + +pub struct Mail<'a> { + pub smtp_transport: &'a Arc, + pub tera: &'a Arc, + pub context: tera::Context, + pub to: String, + pub subject: String, + pub text_body_template: String, + pub html_body_template: Option, +} + +impl<'a> Mail<'a> { + pub fn new( + smtp_transport: &'a Arc, + tera: &'a Arc, + context: tera::Context, + to: String, + subject: String, + text_body_template: String, + html_body_template: Option, + ) -> Self { + Mail { + smtp_transport, + tera, + to, + subject, + text_body_template, + html_body_template, + context, + } + } + + pub async fn send(&self) -> Result<(), ABError> { + let tera = self.tera.clone(); + let context = self.context.clone(); + let text_body_template = self.text_body_template.clone(); + let text_body = run_blocking!({ + tera.render(&text_body_template, &context).map_err(|e| { + ABError::InternalServerError(format!("Template rendering error: {}", e)) + }) + })?; + + let email_builder = Message::builder() + .from(Mailbox::new( + Some("Airborne No Reply".to_owned()), + "no-reply@airborne.io".parse().unwrap(), + )) + .to(Mailbox::new( + None, + self.to.parse::
().map_err(|e| { + ABError::InternalServerError(format!("Invalid email address: {}", e)) + })?, + )) + .subject(self.subject.clone()); + + let email = match &self.html_body_template { + Some(html_body_template) => { + let tera = self.tera.clone(); + let context = self.context.clone(); + let html_body_template = html_body_template.clone(); + let html_body = run_blocking!({ + tera.render(&html_body_template, &context).map_err(|e| { + ABError::InternalServerError(format!("Template rendering error: {}", e)) + }) + })?; + + email_builder + .multipart( + lettre::message::MultiPart::alternative() + .singlepart(lettre::message::SinglePart::plain(text_body.clone())) + .singlepart(lettre::message::SinglePart::html(html_body.clone())), + ) + .map_err(|e| { + ABError::InternalServerError(format!("Email building error: {}", e)) + })? + } + None => email_builder + .singlepart(lettre::message::SinglePart::plain(text_body.clone())) + .map_err(|e| { + ABError::InternalServerError(format!("Email building error: {}", e)) + })?, + }; + + let mailer = self.smtp_transport.clone(); + let _ = run_blocking!({ + mailer.send(&email).map_err(|e| { + error!("Could not send email: {:?}", e); + ABError::InternalServerError(format!("Email sending error: {}", e)) + }) + })?; + + Ok(()) + } +} diff --git a/airborne_server/templates/org_invitation.html b/airborne_server/templates/org_invitation.html new file mode 100644 index 00000000..1d84cab2 --- /dev/null +++ b/airborne_server/templates/org_invitation.html @@ -0,0 +1,86 @@ + + + + + Invitation to join {{ organization }} on Airborne + + + + + + + + +
+ + + + + + + + + + + + + + + +
+ Airborne Logo +

You're invited!

+

Join your team and start building with Airborne

+
+

Hello,

+ +

+ You've been invited to join {{ organization }} + on Airborne with + {{ role }} + access. +

+ + + + + + +
+

Airborne helps you to:

+
    +
  • ๐Ÿš€ Deploy and manage applications seamlessly
  • +
  • ๐Ÿ“Š Real-time analytics and performance monitoring
  • +
  • ๐Ÿ”’ Enterprise-grade security and access controls
  • +
  • ๐Ÿ‘ฅ Collaborate with your team members
  • +
  • โšก Advanced targeting and rollout controls
  • +
+
+ + +
+

Ready to get started? Accept your invitation to join the team:

+ + Accept Invitation + +
+ + +
+

+ Important: This invitation will expire in 7 days. If you have any questions or need assistance, please reach out to your team administrator or our support team. +

+
+
+

This email was sent by Airborne on behalf of {{ organization }}

+

If you believe this email was sent to you by mistake, you can safely ignore it.

+

+ Learn more ยท + Contact Support ยท + Documentation +

+
+
+ + + diff --git a/airborne_server/templates/org_invitation.txt b/airborne_server/templates/org_invitation.txt new file mode 100644 index 00000000..0100885f --- /dev/null +++ b/airborne_server/templates/org_invitation.txt @@ -0,0 +1,22 @@ +Hi, + +Youโ€™ve been invited to join {{ organization }} on Airborne as a {{ role }}. + +Airborne helps you to: +1. Deploy and manage applications seamlessly +2. Monitor performance and analytics in real time +3. Benefit from enterprise-grade security and access controls +4. Collaborate with your team members +5. Explore advanced rollout and targeting controls + +To get started, please accept your invitation here: {{ invitation_url }} + +This invitation will expire in 7 days. If you have any questions or need assistance, contact your team administrator or reach out to our support team at superposition@juspay.in. + +This email was sent by Airborne on behalf of {{ organization }}. +If you believe this was sent to you by mistake, you can safely ignore it. + +Learn more about Airborne: https://airborne.juspay.in +Documentation: https://airborne.juspay.in/docs/home +โ€” +The Airborne Team \ No newline at end of file