From 54188b2c8a2898a9032e8f5d4df42783900a48d3 Mon Sep 17 00:00:00 2001 From: Philip Miglinci Date: Thu, 21 May 2026 16:12:21 +0200 Subject: [PATCH] feat: introduce non-personalized service accounts Adds a ServiceAccount principal type that lives alongside UserAccount. Service accounts are non-interactive identities admins create within a vendor org (and optionally scoped to a customer org). Each can hold any number of access tokens that authenticate against the existing "AccessToken: distr-..." header path, so SDK and client code do not change. Backend - New ServiceAccount and ServiceAccountAccessToken tables (migration 100) with vendor/customer-org scoping. - internal/types/service_account.go, internal/db/service_account*.go, internal/mapping/service_account.go, api/service_account.go. - AuthInfo gains CurrentServiceAccountID. SimpleAuthInfo carries a serviceAccountID, DbAuthInfo a *ServiceAccount. - FromServiceAccountAuthKey + UnifiedAuthKeyAuthenticator try user-PAT lookup first, fall back to SA-token lookup. Wired into both Authentication and ArtifactsAuthentication so OCI registry works. - BlockServiceAccount middleware for routes that must reject SA tokens. - /api/v1/service-accounts CRUD + per-SA /tokens, admin-only via RequireAdmin + BlockSuperAdmin + BlockServiceAccount. Customer-org scoping enforced in handlers and middleware. Frontend - SDK ServiceAccount types. - ServiceAccountsService + ServiceAccountsComponent (table below users on both vendor and customer-users pages, admin-only). - ServiceAccountDetailComponent at /users/service-accounts/:id for inline name/role edit and token management. - Extracted AccessTokensTableComponent shared between the personal PAT page and the SA detail page (removes duplication). Docs - website/.../integrations/service-account.md describing creation, admin gating, customer scoping, and curl/OCI usage. --- api/service_account.go | 41 +++ .../access-tokens-table.component.html | 141 ++++++++++ .../access-tokens-table.component.ts | 107 ++++++++ .../access-tokens/access-tokens.component.ts | 107 +------- frontend/ui/src/app/app-logged-in.routes.ts | 6 + .../customers/customer-users.component.html | 3 + .../customers/customer-users.component.ts | 5 +- .../users/vendors/vendor-users.component.ts | 18 +- .../service-account-detail.component.html | 80 ++++++ .../service-account-detail.component.ts | 91 +++++++ .../service-accounts.component.html} | 111 +++----- .../service-accounts.component.ts | 109 ++++++++ .../app/services/service-accounts.service.ts | 49 ++++ internal/auth/authentication.go | 11 +- internal/authn/authinfo/authinfo.go | 4 + internal/authn/authinfo/db.go | 37 ++- internal/authn/authinfo/service_account.go | 53 ++++ internal/authn/authinfo/simple.go | 4 + internal/context/data.go | 11 + internal/context/main.go | 1 + internal/db/service_account.go | 175 +++++++++++++ internal/db/service_account_access_token.go | 133 ++++++++++ .../handlers/service_account_access_tokens.go | 113 +++++++++ internal/handlers/service_accounts.go | 240 ++++++++++++++++++ internal/mapping/service_account.go | 26 ++ internal/middleware/middleware.go | 12 + .../sql/100_service_accounts.down.sql | 3 + .../sql/100_service_accounts.up.sql | 25 ++ internal/routing/routing.go | 1 + internal/types/service_account.go | 32 +++ sdk/js/docs/README.md | 3 + .../interfaces/CreateServiceAccountRequest.md | 25 ++ .../interfaces/PatchServiceAccountRequest.md | 19 ++ sdk/js/docs/interfaces/ServiceAccount.md | 37 +++ sdk/js/src/types/index.ts | 1 + sdk/js/src/types/service-account.ts | 20 ++ .../docs/docs/integrations/service-account.md | 64 +++++ 37 files changed, 1739 insertions(+), 179 deletions(-) create mode 100644 api/service_account.go create mode 100644 frontend/ui/src/app/access-tokens/access-tokens-table.component.html create mode 100644 frontend/ui/src/app/access-tokens/access-tokens-table.component.ts create mode 100644 frontend/ui/src/app/service-accounts/service-account-detail.component.html create mode 100644 frontend/ui/src/app/service-accounts/service-account-detail.component.ts rename frontend/ui/src/app/{access-tokens/access-tokens.component.html => service-accounts/service-accounts.component.html} (60%) create mode 100644 frontend/ui/src/app/service-accounts/service-accounts.component.ts create mode 100644 frontend/ui/src/app/services/service-accounts.service.ts create mode 100644 internal/authn/authinfo/service_account.go create mode 100644 internal/db/service_account.go create mode 100644 internal/db/service_account_access_token.go create mode 100644 internal/handlers/service_account_access_tokens.go create mode 100644 internal/handlers/service_accounts.go create mode 100644 internal/mapping/service_account.go create mode 100644 internal/migrations/sql/100_service_accounts.down.sql create mode 100644 internal/migrations/sql/100_service_accounts.up.sql create mode 100644 internal/types/service_account.go create mode 100644 sdk/js/docs/interfaces/CreateServiceAccountRequest.md create mode 100644 sdk/js/docs/interfaces/PatchServiceAccountRequest.md create mode 100644 sdk/js/docs/interfaces/ServiceAccount.md create mode 100644 sdk/js/src/types/service-account.ts create mode 100644 website/src/content/docs/docs/integrations/service-account.md diff --git a/api/service_account.go b/api/service_account.go new file mode 100644 index 000000000..868f2af08 --- /dev/null +++ b/api/service_account.go @@ -0,0 +1,41 @@ +package api + +import ( + "time" + + "github.com/distr-sh/distr/internal/types" + "github.com/google/uuid" +) + +type ServiceAccountResponse struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Name string `json:"name"` + AccountRole types.AccountRole `json:"accountRole"` + CustomerOrganizationID *uuid.UUID `json:"customerOrganizationId,omitempty"` +} + +type CreateServiceAccountRequest struct { + Name string `json:"name"` + AccountRole types.AccountRole `json:"accountRole"` + CustomerOrganizationID *uuid.UUID `json:"customerOrganizationId,omitempty"` +} + +type PatchServiceAccountRequest struct { + Name *string `json:"name"` + AccountRole *types.AccountRole `json:"accountRole"` +} + +type ServiceAccountIDRequest struct { + ServiceAccountID string `json:"-" path:"serviceAccountId"` +} + +type ServiceAccountAccessTokenIDRequest struct { + ServiceAccountID string `json:"-" path:"serviceAccountId"` + TokenID string `json:"-" path:"tokenId"` +} + +type CreateServiceAccountAccessTokenRequest struct { + ExpiresAt *time.Time `json:"expiresAt"` + Label *string `json:"label"` +} diff --git a/frontend/ui/src/app/access-tokens/access-tokens-table.component.html b/frontend/ui/src/app/access-tokens/access-tokens-table.component.html new file mode 100644 index 000000000..809579414 --- /dev/null +++ b/frontend/ui/src/app/access-tokens/access-tokens-table.component.html @@ -0,0 +1,141 @@ +
+
+
+
+ +
+
+ + @if (createdToken(); as t) { + + } + +
+ + + + + + + + + + + + @for (token of accessTokens$ | async; track token.id) { + + + + + + + + } @empty { + + + + } + +
LabelCreation DateExpiresLast Used
{{ token.label }}{{ token.createdAt | date: 'medium' }} + @if (token.expiresAt; as d) { + {{ d | date }} + @if (isExpired(token)) { + (expired) + } + } @else { + never + } + + @if (token.lastUsedAt; as d) { + {{ d | relativeDate }} + } @else { + never + } + + +
No tokens yet.
+
+
+ + +
+
+ {{ drawerTitle() }} +
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
diff --git a/frontend/ui/src/app/access-tokens/access-tokens-table.component.ts b/frontend/ui/src/app/access-tokens/access-tokens-table.component.ts new file mode 100644 index 000000000..0915363ff --- /dev/null +++ b/frontend/ui/src/app/access-tokens/access-tokens-table.component.ts @@ -0,0 +1,107 @@ +import {OverlayModule} from '@angular/cdk/overlay'; +import {AsyncPipe, DatePipe} from '@angular/common'; +import {Component, inject, input, signal, TemplateRef} from '@angular/core'; +import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {AccessToken, AccessTokenWithKey, CreateAccessTokenRequest} from '@distr-sh/distr-sdk'; +import {FaIconComponent} from '@fortawesome/angular-fontawesome'; +import {faPlus, faTrash, faXmark} from '@fortawesome/free-solid-svg-icons'; +import dayjs from 'dayjs'; +import {firstValueFrom, Observable, startWith, Subject, switchMap} from 'rxjs'; +import {isExpired, RelativeDatePipe} from '../../util/dates'; +import {ClipComponent} from '../components/clip.component'; +import {AutotrimDirective} from '../directives/autotrim.directive'; +import {DialogRef, OverlayService} from '../services/overlay.service'; +import {ToastService} from '../services/toast.service'; + +export interface AccessTokenStore { + list(): Observable; + create(request: CreateAccessTokenRequest): Observable; + delete(id: string): Observable; +} + +@Component({ + selector: 'app-access-tokens-table', + imports: [ + AsyncPipe, + AutotrimDirective, + ClipComponent, + DatePipe, + FaIconComponent, + OverlayModule, + ReactiveFormsModule, + RelativeDatePipe, + ], + templateUrl: './access-tokens-table.component.html', +}) +export class AccessTokensTableComponent { + public readonly store = input.required(); + public readonly drawerTitle = input('Create access token'); + public readonly keyDisplayText = input('Your access token:'); + + protected readonly faPlus = faPlus; + protected readonly faTrash = faTrash; + protected readonly faXmark = faXmark; + protected readonly isExpired = isExpired; + + private readonly overlay = inject(OverlayService); + private readonly toast = inject(ToastService); + + private readonly refresh$ = new Subject(); + protected readonly accessTokens$ = this.refresh$.pipe( + startWith(0), + switchMap(() => this.store().list()) + ); + + protected readonly editForm = new FormGroup({ + label: new FormControl('', {nonNullable: true}), + expiresAt: new FormControl('', {nonNullable: true}), + }); + protected editFormLoading = signal(false); + protected createdToken = signal(null); + protected drawer: DialogRef | null = null; + + public openDrawer(template: TemplateRef) { + this.hideDrawer(); + this.editForm.patchValue({ + label: '', + expiresAt: dayjs() + .add(dayjs.duration({days: 30})) + .format('YYYY-MM-DD'), + }); + this.drawer = this.overlay.showDrawer(template); + } + + public hideDrawer() { + this.drawer?.dismiss(); + } + + public async createAccessToken() { + this.editFormLoading.set(true); + const request: CreateAccessTokenRequest = {}; + if (this.editForm.value.label) { + request.label = this.editForm.value.label; + } + if (this.editForm.value.expiresAt) { + request.expiresAt = new Date(this.editForm.value.expiresAt); + } + try { + this.createdToken.set(await firstValueFrom(this.store().create(request))); + this.toast.success('Token created'); + this.hideDrawer(); + this.refresh$.next(); + } finally { + this.editFormLoading.set(false); + } + } + + public async deleteAccessToken(accessToken: AccessToken) { + if (await firstValueFrom(this.overlay.confirm(`Really delete token '${accessToken.label}'?`))) { + try { + await firstValueFrom(this.store().delete(accessToken.id!)); + this.refresh$.next(); + } catch { + // toast handled globally + } + } + } +} diff --git a/frontend/ui/src/app/access-tokens/access-tokens.component.ts b/frontend/ui/src/app/access-tokens/access-tokens.component.ts index 6179e6ea1..e3358c1cc 100644 --- a/frontend/ui/src/app/access-tokens/access-tokens.component.ts +++ b/frontend/ui/src/app/access-tokens/access-tokens.component.ts @@ -1,102 +1,19 @@ -import {OverlayModule} from '@angular/cdk/overlay'; -import {AsyncPipe, DatePipe} from '@angular/common'; -import {Component, inject, TemplateRef} from '@angular/core'; -import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; -import {AccessToken, AccessTokenWithKey, CreateAccessTokenRequest} from '@distr-sh/distr-sdk'; -import {FaIconComponent} from '@fortawesome/angular-fontawesome'; -import {faClipboard, faMagnifyingGlass, faPlus, faTrash, faXmark} from '@fortawesome/free-solid-svg-icons'; -import dayjs from 'dayjs'; -import {firstValueFrom, startWith, Subject, switchMap} from 'rxjs'; -import {isExpired, RelativeDatePipe} from '../../util/dates'; -import {ClipComponent} from '../components/clip.component'; -import {AutotrimDirective} from '../directives/autotrim.directive'; +import {Component, inject} from '@angular/core'; import {AccessTokensService} from '../services/access-tokens.service'; -import {DialogRef, OverlayService} from '../services/overlay.service'; -import {ToastService} from '../services/toast.service'; +import {AccessTokensTableComponent} from './access-tokens-table.component'; @Component({ selector: 'app-access-tokens', - imports: [ - ReactiveFormsModule, - FaIconComponent, - AsyncPipe, - DatePipe, - AutotrimDirective, - OverlayModule, - RelativeDatePipe, - ClipComponent, - ], - templateUrl: './access-tokens.component.html', + imports: [AccessTokensTableComponent], + template: `
+
+ +
+
`, }) export class AccessTokensComponent { - protected readonly faMagnifyingGlass = faMagnifyingGlass; - protected readonly faTrash = faTrash; - protected readonly faPlus = faPlus; - protected readonly faXmark = faXmark; - protected readonly faClipboard = faClipboard; - - private readonly accessTokens = inject(AccessTokensService); - private readonly refresh$ = new Subject(); - protected readonly accessTokens$ = this.refresh$.pipe( - startWith(0), - switchMap(() => this.accessTokens.list()) - ); - - private readonly toast = inject(ToastService); - - private readonly overlay = inject(OverlayService); - protected drawer: DialogRef | null = null; - - protected readonly editForm = new FormGroup({ - label: new FormControl('', {nonNullable: true}), - expiresAt: new FormControl('', {nonNullable: true}), - }); - - protected editFormLoading = false; - protected createdToken: AccessTokenWithKey | null = null; - - public openDrawer(template: TemplateRef) { - this.hideDrawer(); - this.editForm.patchValue({ - label: '', - expiresAt: dayjs() - .add(dayjs.duration({days: 30})) - .format('YYYY-MM-DD'), - }); - this.drawer = this.overlay.showDrawer(template); - } - - public hideDrawer() { - this.drawer?.dismiss(); - } - - public async createAccessToken() { - this.editFormLoading = true; - const request: CreateAccessTokenRequest = {}; - if (this.editForm.value.label) { - request.label = this.editForm.value.label; - } - if (this.editForm.value.expiresAt) { - request.expiresAt = new Date(this.editForm.value.expiresAt); - } - try { - this.createdToken = await firstValueFrom(this.accessTokens.create(request)); - this.toast.success('token created'); - this.hideDrawer(); - this.refresh$.next(); - } finally { - this.editFormLoading = false; - } - } - - public async deleteAccessToken(accessToken: AccessToken) { - if (await firstValueFrom(this.overlay.confirm(`Really delete token '${accessToken.label}'?`))) { - try { - await firstValueFrom(this.accessTokens.delete(accessToken.id!)); - this.refresh$.next(); - } catch (e) {} - } - } - - protected readonly isExpired = isExpired; + protected readonly accessTokens = inject(AccessTokensService); } diff --git a/frontend/ui/src/app/app-logged-in.routes.ts b/frontend/ui/src/app/app-logged-in.routes.ts index d1b8f7ccc..704814ef8 100644 --- a/frontend/ui/src/app/app-logged-in.routes.ts +++ b/frontend/ui/src/app/app-logged-in.routes.ts @@ -198,6 +198,12 @@ export const routes: Routes = [ component: VendorUsersComponent, canActivate: [requiredRoleGuard('admin')], }, + { + path: 'users/service-accounts/:serviceAccountId', + loadComponent: () => + import('./service-accounts/service-account-detail.component').then((m) => m.ServiceAccountDetailComponent), + canActivate: [requiredRoleGuard('admin')], + }, { path: 'secrets', component: SecretsPage, diff --git a/frontend/ui/src/app/components/users/customers/customer-users.component.html b/frontend/ui/src/app/components/users/customers/customer-users.component.html index ffab91346..e5ab4bb65 100644 --- a/frontend/ui/src/app/components/users/customers/customer-users.component.html +++ b/frontend/ui/src/app/components/users/customers/customer-users.component.html @@ -60,3 +60,6 @@ +@if (auth.hasRole('admin')) { + +} diff --git a/frontend/ui/src/app/components/users/customers/customer-users.component.ts b/frontend/ui/src/app/components/users/customers/customer-users.component.ts index f223bb4fb..8cdb5813e 100644 --- a/frontend/ui/src/app/components/users/customers/customer-users.component.ts +++ b/frontend/ui/src/app/components/users/customers/customer-users.component.ts @@ -5,15 +5,18 @@ import {ActivatedRoute, RouterLink} from '@angular/router'; import {FontAwesomeModule} from '@fortawesome/angular-fontawesome'; import {faBoxesStacked, faChevronDown} from '@fortawesome/free-solid-svg-icons'; import {combineLatest, map, startWith, Subject, switchMap} from 'rxjs'; +import {ServiceAccountsComponent} from '../../../service-accounts/service-accounts.component'; +import {AuthService} from '../../../services/auth.service'; import {CustomerOrganizationsService} from '../../../services/customer-organizations.service'; import {UsersService} from '../../../services/users.service'; import {UsersComponent} from '../users.component'; @Component({ templateUrl: './customer-users.component.html', - imports: [UsersComponent, RouterLink, FontAwesomeModule, OverlayModule], + imports: [UsersComponent, ServiceAccountsComponent, RouterLink, FontAwesomeModule, OverlayModule], }) export class CustomerUsersComponent { + protected readonly auth = inject(AuthService); protected readonly faBoxesStacked = faBoxesStacked; protected readonly faChevronDown = faChevronDown; diff --git a/frontend/ui/src/app/components/users/vendors/vendor-users.component.ts b/frontend/ui/src/app/components/users/vendors/vendor-users.component.ts index 8c5432f77..8dc9d50d4 100644 --- a/frontend/ui/src/app/components/users/vendors/vendor-users.component.ts +++ b/frontend/ui/src/app/components/users/vendors/vendor-users.component.ts @@ -1,23 +1,27 @@ import {Component, inject} from '@angular/core'; import {toSignal} from '@angular/core/rxjs-interop'; import {map, startWith, Subject, switchMap} from 'rxjs'; +import {ServiceAccountsComponent} from '../../../service-accounts/service-accounts.component'; import {AuthService} from '../../../services/auth.service'; import {UsersService} from '../../../services/users.service'; import {UsersComponent} from '../users.component'; @Component({ template: `
-
-
- +
+
+ +
-
-
`, - imports: [UsersComponent], + + @if (auth.hasRole('admin')) { + + }`, + imports: [UsersComponent, ServiceAccountsComponent], }) export class VendorUsersComponent { private readonly usersService = inject(UsersService); - private readonly auth = inject(AuthService); + protected readonly auth = inject(AuthService); protected readonly refresh$ = new Subject(); protected readonly users = toSignal( this.refresh$.pipe( diff --git a/frontend/ui/src/app/service-accounts/service-account-detail.component.html b/frontend/ui/src/app/service-accounts/service-account-detail.component.html new file mode 100644 index 000000000..50f675616 --- /dev/null +++ b/frontend/ui/src/app/service-accounts/service-account-detail.component.html @@ -0,0 +1,80 @@ +
+
+ + + Back to users + + + @if (serviceAccount(); as sa) { +
+ @if (!editing()) { +
+
+

{{ sa.name }}

+

+ Role: {{ sa.accountRole | titlecase }} +

+

Created {{ sa.createdAt | date: 'medium' }}

+
+ +
+ } @else { +
+
+ + +
+
+ + +
+
+ + +
+
+ } +
+ + + } @else { +
Loading service account…
+ } +
+
diff --git a/frontend/ui/src/app/service-accounts/service-account-detail.component.ts b/frontend/ui/src/app/service-accounts/service-account-detail.component.ts new file mode 100644 index 000000000..1f43b87b3 --- /dev/null +++ b/frontend/ui/src/app/service-accounts/service-account-detail.component.ts @@ -0,0 +1,91 @@ +import {DatePipe, TitleCasePipe} from '@angular/common'; +import {Component, computed, inject, signal} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {ActivatedRoute, RouterLink} from '@angular/router'; +import {AccountRole} from '@distr-sh/distr-sdk'; +import {FaIconComponent} from '@fortawesome/angular-fontawesome'; +import {faArrowLeft} from '@fortawesome/free-solid-svg-icons'; +import {firstValueFrom, startWith, Subject, switchMap} from 'rxjs'; +import {AccessTokensTableComponent, AccessTokenStore} from '../access-tokens/access-tokens-table.component'; +import {AutotrimDirective} from '../directives/autotrim.directive'; +import {ServiceAccountsService} from '../services/service-accounts.service'; +import {ToastService} from '../services/toast.service'; + +@Component({ + selector: 'app-service-account-detail', + imports: [ + AccessTokensTableComponent, + AutotrimDirective, + DatePipe, + FaIconComponent, + ReactiveFormsModule, + RouterLink, + TitleCasePipe, + ], + templateUrl: './service-account-detail.component.html', +}) +export class ServiceAccountDetailComponent { + protected readonly faArrowLeft = faArrowLeft; + + private readonly route = inject(ActivatedRoute); + private readonly service = inject(ServiceAccountsService); + private readonly toast = inject(ToastService); + + protected readonly serviceAccountId = computed(() => this.route.snapshot.paramMap.get('serviceAccountId') ?? ''); + + private readonly refreshSA$ = new Subject(); + protected readonly serviceAccount = toSignal( + this.refreshSA$.pipe( + startWith(0), + switchMap(() => this.service.get(this.serviceAccountId())) + ) + ); + + protected readonly tokenStore: AccessTokenStore = { + list: () => this.service.listTokens(this.serviceAccountId()), + create: (request) => this.service.createToken(this.serviceAccountId(), request), + delete: (tokenId) => this.service.deleteToken(this.serviceAccountId(), tokenId), + }; + + protected readonly editForm = new FormGroup({ + name: new FormControl('', {nonNullable: true, validators: [Validators.required]}), + accountRole: new FormControl('read_write', {nonNullable: true}), + }); + protected editLoading = signal(false); + protected editing = signal(false); + + public startEdit() { + const sa = this.serviceAccount(); + if (!sa) { + return; + } + this.editForm.reset({name: sa.name, accountRole: sa.accountRole}); + this.editing.set(true); + } + + public cancelEdit() { + this.editing.set(false); + } + + public async saveEdit() { + const sa = this.serviceAccount(); + if (!sa || this.editForm.invalid) { + return; + } + this.editLoading.set(true); + try { + await firstValueFrom( + this.service.patch(sa.id, { + name: this.editForm.value.name, + accountRole: this.editForm.value.accountRole, + }) + ); + this.toast.success('Service account updated'); + this.editing.set(false); + this.refreshSA$.next(); + } finally { + this.editLoading.set(false); + } + } +} diff --git a/frontend/ui/src/app/access-tokens/access-tokens.component.html b/frontend/ui/src/app/service-accounts/service-accounts.component.html similarity index 60% rename from frontend/ui/src/app/access-tokens/access-tokens.component.html rename to frontend/ui/src/app/service-accounts/service-accounts.component.html index 7b03fb2e5..e25609130 100644 --- a/frontend/ui/src/app/access-tokens/access-tokens.component.html +++ b/frontend/ui/src/app/service-accounts/service-accounts.component.html @@ -1,84 +1,60 @@ -
-
+

Service Accounts

- - @if (createdToken; as t) { - - }
- - - - + + + - @for (token of accessTokens$ | async; track token.id) { + @for (sa of serviceAccounts$ | async; track sa.id) { - - - - + + + + } @empty { + + + }
LabelCreation DateExpiresLast UsedNameRoleCreated
{{ token.label }}{{ token.createdAt | date: 'medium' }} - @if (token.expiresAt; as d) { - {{ d | date }} - @if (isExpired(token)) { - (expired) - } - } @else { - never - } - - @if (token.lastUsedAt; as d) { - {{ d | relativeDate }} - } @else { - never - } - {{ sa.name }}{{ sa.accountRole | titlecase }}{{ sa.createdAt | date: 'medium' }} + + + Manage tokens +
+ No service accounts yet. +
@@ -87,18 +63,14 @@
- +
-
- Create a Personal Access Token + tabindex="-1"> +
+ Create a service account
-
+
- + + placeholder="ci-bot" />
- - + +
-