Feat/credentials (#2290)

add credentials and remove properties
This commit is contained in:
Matt Hill
2023-05-31 12:29:48 -06:00
committed by Aiden McClelland
parent 5bcad69cf7
commit 7213d82f1b
14 changed files with 159 additions and 459 deletions

View File

@@ -11,7 +11,6 @@ const ICONS = [
'arrow-back',
'arrow-forward',
'arrow-up',
'briefcase-outline',
'brush-outline',
'bookmark-outline',
'cellular-outline',

View File

@@ -2,18 +2,19 @@ import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppPropertiesPage } from './app-properties.page'
import { AppCredentialsPage } from './app-credentials.page'
import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { MaskPipeModule } from 'src/app/pipes/mask/mask.module'
import {
SharedPipesModule,
TextSpinnerComponentModule,
} from '@start9labs/shared'
import { SkeletonListComponentModule } from 'src/app/components/skeleton-list/skeleton-list.component.module'
const routes: Routes = [
{
path: '',
component: AppPropertiesPage,
component: AppCredentialsPage,
},
]
@@ -26,7 +27,8 @@ const routes: Routes = [
SharedPipesModule,
TextSpinnerComponentModule,
MaskPipeModule,
SkeletonListComponentModule,
],
declarations: [AppPropertiesPage],
declarations: [AppCredentialsPage],
})
export class AppPropertiesPageModule {}
export class AppCredentialsPageModule {}

View File

@@ -0,0 +1,58 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Credentials</ion-title>
<ion-buttons *ngIf="!loading" slot="end">
<ion-button (click)="refresh()">
<ion-icon slot="start" name="refresh"></ion-icon>
Refresh
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top with-widgets">
<!-- loading -->
<skeleton-list *ngIf="loading; else loaded"></skeleton-list>
<!-- loaded -->
<ng-template #loaded>
<!-- no credentials -->
<ion-item *ngIf="credentials | empty else hasCredentials">
<ion-label>
<p>No credentials</p>
</ion-label>
</ion-item>
<ng-template #hasCredentials>
<ion-item-group>
<ion-item *ngFor="let cred of credentials | keyvalue: asIsOrder">
<ion-label>
<h2>{{ cred.key }}</h2>
<p class="courier-new">
{{ unmasked[cred.key] ? cred.value : (cred.value | mask : 64) }}
</p>
</ion-label>
<div slot="end">
<ion-button fill="clear" (click)="toggleMask(cred.key)">
<ion-icon
slot="icon-only"
[name]="unmasked[cred.key] ? 'eye-off-outline' : 'eye-outline'"
size="small"
></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="copy(cred.value)">
<ion-icon
slot="icon-only"
name="copy-outline"
size="small"
></ion-icon>
</ion-button>
</div>
</ion-item>
</ion-item-group>
</ng-template>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,72 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ToastController } from '@ionic/angular'
import {
ErrorToastService,
getPkgId,
copyToClipboard,
pauseFor,
} from '@start9labs/shared'
@Component({
selector: 'app-credentials',
templateUrl: './app-credentials.page.html',
styleUrls: ['./app-credentials.page.scss'],
})
export class AppCredentialsPage {
readonly pkgId = getPkgId(this.route)
credentials: Record<string, string> = {}
unmasked: { [key: string]: boolean } = {}
loading = true
constructor(
private readonly route: ActivatedRoute,
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
private readonly toastCtrl: ToastController,
) {}
async ngOnInit() {
await this.getCredentials()
}
async refresh() {
await this.getCredentials()
}
async copy(text: string): Promise<void> {
const success = await copyToClipboard(text)
const message = success
? 'Copied. Clearing clipboard in 20 seconds'
: 'Failed to copy.'
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 2000,
})
await toast.present()
}
toggleMask(key: string) {
this.unmasked[key] = !this.unmasked[key]
}
private async getCredentials(): Promise<void> {
this.loading = true
try {
this.credentials = await this.embassyApi.getPackageCredentials({
id: this.pkgId,
})
} catch (e: any) {
this.errToast.present(e)
} finally {
this.loading = false
}
}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -1,119 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Properties</ion-title>
<ion-buttons *ngIf="!loading" slot="end">
<ion-button (click)="refresh()">
<ion-icon slot="start" name="refresh"></ion-icon>
Refresh
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top with-widgets">
<text-spinner
*ngIf="loading; else loaded"
text="Loading Properties"
></text-spinner>
<ng-template #loaded>
<!-- not running -->
<ion-item *ngIf="stopped$ | async" class="ion-margin-bottom">
<ion-label>
<p>
<ion-text color="warning">
Service is stopped. Information on this page could be inaccurate.
</ion-text>
</p>
</ion-label>
</ion-item>
<!-- no properties -->
<ion-item *ngIf="properties | empty">
<ion-label>
<p>No properties.</p>
</ion-label>
</ion-item>
<!-- properties -->
<ion-item-group *ngIf="!(properties | empty)">
<div *ngFor="let prop of node | keyvalue: asIsOrder">
<!-- object -->
<ion-item
button
detail="true"
*ngIf="prop.value.type === 'object'"
(click)="goToNested(prop.key)"
>
<ion-button
*ngIf="prop.value.description"
fill="clear"
slot="start"
(click)="presentDescription(prop, $event)"
>
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label>
<h2>{{ prop.key }}</h2>
</ion-label>
</ion-item>
<!-- not object -->
<ion-item *ngIf="prop.value.type === 'string'">
<ion-button
*ngIf="prop.value.description"
fill="clear"
slot="start"
(click)="presentDescription(prop, $event)"
>
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label>
<h2>{{ prop.key }}</h2>
<p class="courier-new">
{{ prop.value.masked && !unmasked[prop.key] ? (prop.value.value |
mask : 64) : prop.value.value }}
</p>
</ion-label>
<div slot="end" *ngIf="prop.value.copyable || prop.value.qr">
<ion-button
*ngIf="prop.value.masked"
fill="clear"
(click)="toggleMask(prop.key)"
>
<ion-icon
slot="icon-only"
[name]="unmasked[prop.key] ? 'eye-off-outline' : 'eye-outline'"
size="small"
></ion-icon>
</ion-button>
<ion-button
*ngIf="prop.value.qr"
fill="clear"
(click)="showQR(prop.value.value)"
>
<ion-icon
slot="icon-only"
name="qr-code-outline"
size="small"
></ion-icon>
</ion-button>
<ion-button
*ngIf="prop.value.copyable"
fill="clear"
(click)="copy(prop.value.value)"
>
<ion-icon
slot="icon-only"
name="copy-outline"
size="small"
></ion-icon>
</ion-button>
</div>
</ion-item>
</div>
</ion-item-group>
</ng-template>
</ion-content>

View File

@@ -1,154 +0,0 @@
import { Component, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
AlertController,
IonBackButtonDelegate,
ModalController,
NavController,
ToastController,
} from '@ionic/angular'
import { PackageProperties } from 'src/app/util/properties.util'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { PatchDB } from 'patch-db-client'
import {
DataModel,
PackageMainStatus,
} from 'src/app/services/patch-db/data-model'
import {
ErrorToastService,
getPkgId,
copyToClipboard,
} from '@start9labs/shared'
import { TuiDestroyService } from '@taiga-ui/cdk'
import { getValueByPointer } from 'fast-json-patch'
import { map, takeUntil } from 'rxjs/operators'
@Component({
selector: 'app-properties',
templateUrl: './app-properties.page.html',
styleUrls: ['./app-properties.page.scss'],
providers: [TuiDestroyService],
})
export class AppPropertiesPage {
loading = true
readonly pkgId = getPkgId(this.route)
pointer = ''
node: PackageProperties = {}
properties: PackageProperties = {}
unmasked: { [key: string]: boolean } = {}
stopped$ = this.patch
.watch$('package-data', this.pkgId, 'installed', 'status', 'main', 'status')
.pipe(map(status => status === PackageMainStatus.Stopped))
@ViewChild(IonBackButtonDelegate, { static: false })
backButton?: IonBackButtonDelegate
constructor(
private readonly route: ActivatedRoute,
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController,
private readonly toastCtrl: ToastController,
private readonly modalCtrl: ModalController,
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>,
private readonly destroy$: TuiDestroyService,
) {}
ionViewDidEnter() {
if (!this.backButton) return
this.backButton.onClick = () => {
history.back()
}
}
async ngOnInit() {
await this.getProperties()
this.route.queryParams
.pipe(takeUntil(this.destroy$))
.subscribe(queryParams => {
if (queryParams['pointer'] === this.pointer) return
this.pointer = queryParams['pointer'] || ''
this.node = getValueByPointer(this.properties, this.pointer)
})
}
async refresh() {
await this.getProperties()
}
async presentDescription(
property: { key: string; value: PackageProperties[''] },
e: Event,
) {
e.stopPropagation()
const alert = await this.alertCtrl.create({
header: property.key,
message: property.value.description || undefined,
})
await alert.present()
}
async goToNested(key: string): Promise<any> {
this.navCtrl.navigateForward(`/services/${this.pkgId}/properties`, {
queryParams: {
pointer: `${this.pointer}/${key}/value`,
},
})
}
async copy(text: string): Promise<void> {
let message = ''
await copyToClipboard(text).then(success => {
message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
async showQR(text: string): Promise<void> {
const modal = await this.modalCtrl.create({
component: QRComponent,
componentProps: {
text,
},
cssClass: 'qr-modal',
})
await modal.present()
}
toggleMask(key: string) {
this.unmasked[key] = !this.unmasked[key]
}
private async getProperties(): Promise<void> {
this.loading = true
try {
this.properties = await this.embassyApi.getPackageProperties({
id: this.pkgId,
})
this.node = getValueByPointer(this.properties, this.pointer)
} catch (e: any) {
this.errToast.present(e)
} finally {
this.loading = false
}
}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -62,16 +62,15 @@ export class ToButtonsPipe implements PipeTransform {
description: `Customize ${pkgTitle}`,
icon: 'options-outline',
},
// properties
// credentials
{
action: () =>
this.navCtrl.navigateForward(['properties'], {
this.navCtrl.navigateForward(['credentials'], {
relativeTo: this.route,
}),
title: 'Properties',
description:
'Runtime information, credentials, and other values of interest',
icon: 'briefcase-outline',
title: 'Credentials',
description: 'Password, keys, or other credentials of interest',
icon: 'key-outline',
},
// actions
{

View File

@@ -37,10 +37,10 @@ const routes: Routes = [
import('./app-logs/app-logs.module').then(m => m.AppLogsPageModule),
},
{
path: ':pkgId/properties',
path: ':pkgId/credentials',
loadChildren: () =>
import('./app-properties/app-properties.module').then(
m => m.AppPropertiesPageModule,
import('./app-credentials/app-credentials.module').then(
m => m.AppCredentialsPageModule,
),
},
]

View File

@@ -1,6 +1,5 @@
import { Dump, Revision } from 'patch-db-client'
import { MarketplacePkg, StoreInfo, Manifest } from '@start9labs/marketplace'
import { PackagePropertiesVersioned } from 'src/app/util/properties.util'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import {
DataModel,
@@ -235,9 +234,8 @@ export module RR {
// package
export type GetPackagePropertiesReq = { id: string } // package.properties
export type GetPackagePropertiesRes<T extends number> =
PackagePropertiesVersioned<T>
export type GetPackageCredentialsReq = { id: string } // package.credentials
export type GetPackageCredentialsRes = Record<string, string>
export type GetPackageLogsReq = ServerLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = LogsRes

View File

@@ -234,9 +234,9 @@ export abstract class ApiService {
// package
abstract getPackageProperties(
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']>
abstract getPackageCredentials(
params: RR.GetPackageCredentialsReq,
): Promise<RR.GetPackageCredentialsRes>
abstract getPackageLogs(
params: RR.GetPackageLogsReq,

View File

@@ -12,7 +12,6 @@ import {
} from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import { BackupTargetType, Metrics, RR } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { ConfigService } from '../config.service'
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Observable } from 'rxjs'
@@ -405,12 +404,10 @@ export class LiveApiService extends ApiService {
// package
async getPackageProperties(
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']> {
return this.rpcRequest({ method: 'package.properties', params }).then(
parsePropertiesPermissive,
)
async getPackageCredentials(
params: RR.GetPackageCredentialsReq,
): Promise<RR.GetPackageCredentialsRes> {
return this.rpcRequest({ method: 'package.credentials', params })
}
async getPackageLogs(

View File

@@ -17,7 +17,6 @@ import {
PackageState,
} from 'src/app/services/patch-db/data-model'
import { BackupTargetType, Metrics, RR } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { Mock } from './api.fixures'
import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md'
import {
@@ -667,12 +666,13 @@ export class MockApiService extends ApiService {
// package
async getPackageProperties(
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']> {
async getPackageCredentials(
params: RR.GetPackageCredentialsReq,
): Promise<RR.GetPackageCredentialsRes> {
await pauseFor(2000)
return '' as any
// return parsePropertiesPermissive(Mock.PackageProperties)
return {
password: 'specialPassword$',
}
}
async getPackageLogs(

View File

@@ -1,152 +0,0 @@
import { applyOperation } from 'fast-json-patch'
import matches, {
Parser,
shape,
string,
literal,
boolean,
deferred,
dictionary,
anyOf,
number,
arrayOf,
} from 'ts-matches'
type ValidVersion = 1 | 2
type PropertiesV1 = typeof matchPropertiesV1._TYPE
type PackagePropertiesV1 = PropertiesV1[]
type PackagePropertiesV2 = {
[name: string]: PackagePropertyString | PackagePropertyObject
}
type PackagePropertiesVersionedData<T extends number> = T extends 1
? PackagePropertiesV1
: T extends 2
? PackagePropertiesV2
: never
type PackagePropertyString = typeof matchPackagePropertyString._TYPE
export type PackagePropertiesVersioned<T extends number> = {
version: T
data: PackagePropertiesVersionedData<T>
}
export type PackageProperties = PackagePropertiesV2
const matchPropertiesV1 = shape(
{
name: string,
value: string,
description: string,
copyable: boolean,
qr: boolean,
},
['description', 'copyable', 'qr'],
{ copyable: false, qr: false } as const,
)
const [matchPackagePropertiesV2, setPPV2] = deferred<PackagePropertiesV2>()
const matchPackagePropertyString = shape(
{
type: literal('string'),
description: string,
value: string,
copyable: boolean,
qr: boolean,
masked: boolean,
},
['description', 'copyable', 'qr', 'masked'],
{
copyable: false,
qr: false,
masked: false,
} as const,
)
const matchPackagePropertyObject = shape(
{
type: literal('object'),
value: matchPackagePropertiesV2,
description: string,
},
['description'],
)
const matchPropertyV2 = anyOf(
matchPackagePropertyString,
matchPackagePropertyObject,
)
type PackagePropertyObject = typeof matchPackagePropertyObject._TYPE
setPPV2(dictionary([string, matchPropertyV2]))
const matchPackagePropertiesVersionedV1 = shape({
version: number,
data: arrayOf(matchPropertiesV1),
})
const matchPackagePropertiesVersionedV2 = shape({
version: number,
data: dictionary([string, matchPropertyV2]),
})
export function parsePropertiesPermissive(
properties: unknown,
errorCallback: (err: Error) => any = console.warn,
): PackageProperties {
return matches(properties)
.when(matchPackagePropertiesVersionedV1, prop =>
parsePropertiesV1Permissive(prop.data, errorCallback),
)
.when(matchPackagePropertiesVersionedV2, prop => prop.data)
.when(matches.nill, {})
.defaultToLazy(() => {
errorCallback(new TypeError(`value is not valid`))
return {}
})
}
function parsePropertiesV1Permissive(
properties: unknown,
errorCallback: (err: Error) => any,
): PackageProperties {
if (!Array.isArray(properties)) {
errorCallback(new TypeError(`${properties} is not an array`))
return {}
}
return properties.reduce(
(prev: PackagePropertiesV2, cur: unknown, idx: number) => {
const result = matchPropertiesV1.enumParsed(cur)
if ('value' in result) {
const value = result.value
prev[value.name] = {
type: 'string',
value: value.value,
description: value.description,
copyable: value.copyable,
qr: value.qr,
masked: false,
}
} else {
const error = result.error
const message = Parser.validatorErrorAsString(error)
const dataPath = error.keys.map(removeQuotes).join('/')
errorCallback(new Error(`/data/${idx}: ${message}`))
if (dataPath) {
applyOperation(cur, {
op: 'replace',
path: `/${dataPath}`,
value: undefined,
})
}
}
return prev
},
{},
)
}
const removeRegex = /('|")/
function removeQuotes(x: string) {
while (removeRegex.test(x)) {
x = x.replace(removeRegex, '')
}
return x
}