Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions api/service_account.go
Original file line number Diff line number Diff line change
@@ -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"`
}
141 changes: 141 additions & 0 deletions frontend/ui/src/app/access-tokens/access-tokens-table.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<div class="bg-white dark:bg-gray-800 relative shadow-md sm:rounded-lg overflow-hidden">
<div
class="flex flex-col md:flex-row items-stretch md:items-center md:space-x-3 space-y-3 md:space-y-0 justify-between mx-4 py-4 dark:border-gray-700">
<div></div>
<div
class="w-full md:w-auto flex flex-col md:flex-row space-y-2 md:space-y-0 items-stretch md:items-center justify-end md:space-x-3 flex-shrink-0">
<button
(click)="openDrawer(createTokenDrawer)"
type="button"
class="w-full md:w-auto flex items-center justify-center py-2 px-4 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-primary-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
<fa-icon [icon]="faPlus" class="text-gray-500 dark:text-gray-400 mr-2" />
Create token
</button>
</div>
</div>

@if (createdToken(); as t) {
<div
class="p-4 mx-4 mb-4 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-gray-700 dark:text-green-400"
role="alert">
<p>
{{ keyDisplayText() }}
<code class="select-all" data-ph-mask-text="true">{{ t.key }}</code>
<app-clip class="mx-2" [clip]="t.key" />
</p>
<p>
<strong>Important:</strong>
This is the only time you will be able to see this token, so please make sure to note it down before closing
this page.
</p>
</div>
}

<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead
class="border-t border-gray-200 dark:border-gray-600 text-xs text-gray-700 uppercase bg-gray-100 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="p-4">Label</th>
<th scope="col" class="p-4">Creation Date</th>
<th scope="col" class="p-4">Expires</th>
<th scope="col" class="p-4">Last Used</th>
<th scope="col" class="p-4"></th>
</tr>
</thead>
<tbody>
@for (token of accessTokens$ | async; track token.id) {
<tr class="border-t border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">
<td class="px-4 py-3">{{ token.label }}</td>
<td class="px-4 py-3">{{ token.createdAt | date: 'medium' }}</td>
<td class="px-4 py-3">
@if (token.expiresAt; as d) {
{{ d | date }}
@if (isExpired(token)) {
(expired)
}
} @else {
never
}
</td>
<td class="px-4 py-3">
@if (token.lastUsedAt; as d) {
{{ d | relativeDate }}
} @else {
never
}
</td>
<td
class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white flex justify-end space-x-2">
<button
type="button"
aria-label="Delete"
(click)="deleteAccessToken(token)"
class="py-2 px-3 text-red-700 hover:text-white border border-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm text-center dark:border-red-500 dark:text-red-500 dark:hover:text-white dark:hover:bg-red-600 dark:focus:ring-red-900">
<fa-icon [icon]="faTrash" class="h-4 w-4" />
</button>
</td>
</tr>
} @empty {
<tr>
<td class="px-4 py-6 text-center text-gray-500 dark:text-gray-400" colspan="5">No tokens yet.</td>
</tr>
}
</tbody>
</table>
</div>
</div>

<ng-template #createTokenDrawer>
<div
animate.enter="animate-fly-in-right"
animate.leave="animate-fly-out-right"
class="h-screen p-4 overflow-y-auto bg-white w-80 dark:bg-gray-800"
tabindex="-1">
<h5 class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400">
{{ drawerTitle() }}
</h5>
<button
type="button"
(click)="hideDrawer()"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white">
<fa-icon [icon]="faXmark" class="w-5 h-5" />
<span class="sr-only">Close menu</span>
</button>
<form [formGroup]="editForm" (ngSubmit)="createAccessToken()">
<div class="space-y-4">
<div>
<label for="access-token-label" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Label
</label>
<input
formControlName="label"
autotrim
type="text"
id="access-token-label"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Label this token" />
</div>
<div>
<label for="access-token-expires" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Expires At
</label>
<input
formControlName="expiresAt"
type="date"
id="access-token-expires"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" />
</div>
<div class="flex justify-center w-full pb-4 space-x-4 sm:mt-0">
<button
type="submit"
[disabled]="editFormLoading()"
class="text-white w-full inline-flex items-center justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
<fa-icon [icon]="faPlus" class="me-2" />
Create
</button>
</div>
</div>
</form>
</div>
</ng-template>
107 changes: 107 additions & 0 deletions frontend/ui/src/app/access-tokens/access-tokens-table.component.ts
Original file line number Diff line number Diff line change
@@ -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<AccessToken[]>;
create(request: CreateAccessTokenRequest): Observable<AccessTokenWithKey>;
delete(id: string): Observable<void>;
}

@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<AccessTokenStore>();
public readonly drawerTitle = input<string>('Create access token');
public readonly keyDisplayText = input<string>('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<void>();
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<AccessTokenWithKey | null>(null);
protected drawer: DialogRef<void> | null = null;

public openDrawer(template: TemplateRef<unknown>) {
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
}
}
}
}
Loading
Loading