diff --git a/client/src/app/forms/components/form-errors-alert/form-errors-alert.component.ts b/client/src/app/forms/components/form-errors-alert/form-errors-alert.component.ts index 8c6596a46..ae8beacc1 100644 --- a/client/src/app/forms/components/form-errors-alert/form-errors-alert.component.ts +++ b/client/src/app/forms/components/form-errors-alert/form-errors-alert.component.ts @@ -4,6 +4,7 @@ import { Component, Input, OnInit } from '@angular/core' selector: 'cvc-form-errors-alert', templateUrl: './form-errors-alert.component.html', styleUrls: ['./form-errors-alert.component.less'], + standalone: false, }) export class CvcFormErrorsAlertComponent implements OnInit { @Input() errors!: any diff --git a/client/src/app/forms/components/user-api-keys/user-api-keys.form.html b/client/src/app/forms/components/user-api-keys/user-api-keys.form.html new file mode 100644 index 000000000..59f0eb5b2 --- /dev/null +++ b/client/src/app/forms/components/user-api-keys/user-api-keys.form.html @@ -0,0 +1,85 @@ + + +
+ + + + + +
+ @if(newApiKey(); as key) { + +
    +
  • + {{ key.token }} + {{ key.createdAt | date }} +
      + + + +
    +
  • +
+
+ } + +
    + @for(key of apiKeys(); track key) { +
  • + {{ key.reminder }} + {{ key.createdAt | date }} +
      + + + +
    +
  • + } +
+
+ +
+
diff --git a/client/src/app/forms/components/user-api-keys/user-api-keys.form.less b/client/src/app/forms/components/user-api-keys/user-api-keys.form.less new file mode 100644 index 000000000..2964dd350 --- /dev/null +++ b/client/src/app/forms/components/user-api-keys/user-api-keys.form.less @@ -0,0 +1,11 @@ +:host { + display: block; +} + +nz-space { + width: 100%; +} + +nz-form-item:last-of-type { + margin-bottom: 0; +} diff --git a/client/src/app/forms/components/user-api-keys/user-api-keys.form.ts b/client/src/app/forms/components/user-api-keys/user-api-keys.form.ts new file mode 100644 index 000000000..b78d33268 --- /dev/null +++ b/client/src/app/forms/components/user-api-keys/user-api-keys.form.ts @@ -0,0 +1,171 @@ +import { Component, OnDestroy, signal, WritableSignal } from '@angular/core' +import { NetworkErrorsService } from '@app/core/services/network-errors.service' +import { + MutationState, + MutatorWithState, +} from '@app/core/utilities/mutation-state-wrapper' +import { + ApiKey, + ApiKeysGQL, + GenerateApiKeyGQL, + GenerateApiKeyInput, + GenerateApiKeyMutation, + GenerateApiKeyMutationVariables, + Maybe, + RevokeApiKeyGQL, + RevokeApiKeyInput, + RevokeApiKeyMutation, + RevokeApiKeyMutationVariables, +} from '@app/generated/civic.apollo' +import { CommonModule } from '@angular/common' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { NzCardModule } from 'ng-zorro-antd/card' +import { NzAlertModule } from 'ng-zorro-antd/alert' +import { NzFormModule } from 'ng-zorro-antd/form' +import { CvcFormErrorsAlertModule } from '@app/forms/components/form-errors-alert/form-errors-alert.module' +import { NzRadioModule } from 'ng-zorro-antd/radio' +import { NzSpinModule } from 'ng-zorro-antd/spin' +import { NzButtonModule } from 'ng-zorro-antd/button' + +import { Subject } from 'rxjs' +import { filter, takeUntil } from 'rxjs/operators' +import { NzListModule } from 'ng-zorro-antd/list' +import { NzTypographyModule } from 'ng-zorro-antd/typography' +import { NzIconModule } from 'ng-zorro-antd/icon' +import { NzToolTipModule } from 'ng-zorro-antd/tooltip' +import { isNonNulled } from 'rxjs-etc' +import { NzSpaceModule } from 'ng-zorro-antd/space' +import { NzMessageModule, NzMessageService } from 'ng-zorro-antd/message' + +@Component({ + imports: [ + CommonModule, + FormsModule, + NzFormModule, + ReactiveFormsModule, + NzCardModule, + NzAlertModule, + NzRadioModule, + NzButtonModule, + NzSpinModule, + NzListModule, + NzTypographyModule, + NzIconModule, + NzToolTipModule, + NzSpaceModule, + NzMessageModule, + CvcFormErrorsAlertModule, + ], + standalone: true, + selector: 'cvc-user-api-keys-form', + templateUrl: './user-api-keys.form.html', + styleUrls: ['./user-api-keys.form.less'], +}) +export class CvcUserApiKeysForm implements OnDestroy { + success: boolean = false + errorMessages: string[] = [] + successMessage: string = '' + loading: boolean = false + + private destroy$ = new Subject() + + apiKeys: WritableSignal = signal([]) + newApiKey: WritableSignal> = signal(undefined) + + revokeApiKeyMutator: MutatorWithState< + RevokeApiKeyGQL, + RevokeApiKeyMutation, + RevokeApiKeyMutationVariables + > + + generateApiKeyMutator: MutatorWithState< + GenerateApiKeyGQL, + GenerateApiKeyMutation, + GenerateApiKeyMutationVariables + > + + constructor( + private generateApiKeyGql: GenerateApiKeyGQL, + private revokeApiKeyGql: RevokeApiKeyGQL, + private apiKeysGql: ApiKeysGQL, + private message: NzMessageService, + networkErrorService: NetworkErrorsService + ) { + this.generateApiKeyMutator = new MutatorWithState(networkErrorService) + this.revokeApiKeyMutator = new MutatorWithState(networkErrorService) + apiKeysGql + .watch() + .valueChanges.pipe(filter(isNonNulled), takeUntil(this.destroy$)) + .subscribe(({ data }) => { + if (data?.viewer?.apiKeys) { + this.apiKeys.set(data.viewer.apiKeys) + } + }) + } + + revokeKey(id: number) { + let input: RevokeApiKeyInput = { + id: id, + } + + let state = this.revokeApiKeyMutator.mutate( + this.revokeApiKeyGql, + { input: input }, + { refetchQueries: [{ query: this.apiKeysGql.document }] } + ) + this.manageState(state, 'API Key Revoked Successfully') + } + + generateKey() { + let input: GenerateApiKeyInput = {} + + let state = this.generateApiKeyMutator.mutate( + this.generateApiKeyGql, + { input: input }, + {}, + (data) => { + this.newApiKey.set(data.generateApiKey?.apiKey) + } + ) + + this.manageState( + state, + 'API Key Created. Store It Somewhere Safe, You Will Not Be Able To See It Again' + ) + } + + copyKey(key?: string) { + if (key) { + navigator.clipboard.writeText(key) + this.message.info('Copied') + } + } + + manageState(state: MutationState, message: string) { + this.errorMessages = [] + + state.submitSuccess$.pipe(takeUntil(this.destroy$)).subscribe((res) => { + if (res) { + this.success = true + this.successMessage = message + } + }) + + state.submitError$.pipe(takeUntil(this.destroy$)).subscribe((errs) => { + if (errs) { + this.errorMessages = errs + this.success = false + this.successMessage = '' + } + }) + + state.isSubmitting$.pipe(takeUntil(this.destroy$)).subscribe((loading) => { + this.loading = loading + }) + } + + ngOnDestroy(): void { + this.destroy$.next() + this.destroy$.complete() + } +} diff --git a/client/src/app/forms/components/user-api-keys/user-api-keys.mutation.gql b/client/src/app/forms/components/user-api-keys/user-api-keys.mutation.gql new file mode 100644 index 000000000..d39a3385e --- /dev/null +++ b/client/src/app/forms/components/user-api-keys/user-api-keys.mutation.gql @@ -0,0 +1,28 @@ +mutation GenerateApiKey($input: GenerateApiKeyInput!) { + generateApiKey(input: $input) { + apiKey { + id + reminder + createdAt + token + } + } +} + +mutation RevokeApiKey($input: RevokeApiKeyInput!) { + revokeApiKey(input: $input) { + success + } +} + +query ApiKeys { + viewer { + apiKeys { + id + reminder + createdAt + token + } + } +} + diff --git a/client/src/app/generated/civic.apollo-helpers.ts b/client/src/app/generated/civic.apollo-helpers.ts index 877180504..adb1481b5 100644 --- a/client/src/app/generated/civic.apollo-helpers.ts +++ b/client/src/app/generated/civic.apollo-helpers.ts @@ -85,6 +85,13 @@ export type AdvancedSearchResultFieldPolicy = { resultIds?: FieldPolicy | FieldReadFunction, searchEndpoint?: FieldPolicy | FieldReadFunction }; +export type ApiKeyKeySpecifier = ('createdAt' | 'id' | 'reminder' | 'token' | ApiKeyKeySpecifier)[]; +export type ApiKeyFieldPolicy = { + createdAt?: FieldPolicy | FieldReadFunction, + id?: FieldPolicy | FieldReadFunction, + reminder?: FieldPolicy | FieldReadFunction, + token?: FieldPolicy | FieldReadFunction +}; export type AssertionKeySpecifier = ('acceptanceEvent' | 'acmgCodes' | 'ampLevel' | 'assertionDirection' | 'assertionType' | 'clingenCodes' | 'comments' | 'description' | 'disease' | 'events' | 'evidenceItems' | 'evidenceItemsCount' | 'fdaCompanionTest' | 'fdaCompanionTestLastUpdated' | 'flagged' | 'flags' | 'id' | 'lastAcceptedRevisionEvent' | 'lastCommentEvent' | 'lastSubmittedRevisionEvent' | 'link' | 'molecularProfile' | 'name' | 'nccnGuideline' | 'nccnGuidelineVersion' | 'openRevisionCount' | 'phenotypes' | 'regulatoryApproval' | 'regulatoryApprovalLastUpdated' | 'rejectionEvent' | 'revisions' | 'significance' | 'status' | 'submissionActivity' | 'submissionEvent' | 'summary' | 'therapies' | 'therapyInteractionType' | 'variantOrigin' | AssertionKeySpecifier)[]; export type AssertionFieldPolicy = { acceptanceEvent?: FieldPolicy | FieldReadFunction, @@ -377,8 +384,9 @@ export type BrowseTherapyEdgeFieldPolicy = { cursor?: FieldPolicy | FieldReadFunction, node?: FieldPolicy | FieldReadFunction }; -export type BrowseUserKeySpecifier = ('areaOfExpertise' | 'bio' | 'country' | 'displayName' | 'email' | 'events' | 'evidenceCount' | 'facebookProfile' | 'id' | 'linkedinProfile' | 'mostRecentActivityTimestamp' | 'mostRecentConflictOfInterestStatement' | 'mostRecentEvent' | 'mostRecentOrganizationId' | 'name' | 'notifications' | 'orcid' | 'organizations' | 'profileImagePath' | 'ranks' | 'revisionCount' | 'role' | 'statsHash' | 'twitterHandle' | 'url' | 'username' | BrowseUserKeySpecifier)[]; +export type BrowseUserKeySpecifier = ('apiKeys' | 'areaOfExpertise' | 'bio' | 'country' | 'displayName' | 'email' | 'events' | 'evidenceCount' | 'facebookProfile' | 'id' | 'linkedinProfile' | 'mostRecentActivityTimestamp' | 'mostRecentConflictOfInterestStatement' | 'mostRecentEvent' | 'mostRecentOrganizationId' | 'name' | 'notifications' | 'orcid' | 'organizations' | 'profileImagePath' | 'ranks' | 'revisionCount' | 'role' | 'statsHash' | 'twitterHandle' | 'url' | 'username' | BrowseUserKeySpecifier)[]; export type BrowseUserFieldPolicy = { + apiKeys?: FieldPolicy | FieldReadFunction, areaOfExpertise?: FieldPolicy | FieldReadFunction, bio?: FieldPolicy | FieldReadFunction, country?: FieldPolicy | FieldReadFunction, @@ -1348,6 +1356,12 @@ export type GeneVariantEdgeFieldPolicy = { cursor?: FieldPolicy | FieldReadFunction, node?: FieldPolicy | FieldReadFunction }; +export type GenerateApiKeyPayloadKeySpecifier = ('apiKey' | 'clientMutationId' | 'user' | GenerateApiKeyPayloadKeySpecifier)[]; +export type GenerateApiKeyPayloadFieldPolicy = { + apiKey?: FieldPolicy | FieldReadFunction, + clientMutationId?: FieldPolicy | FieldReadFunction, + user?: FieldPolicy | FieldReadFunction +}; export type LeaderboardOrganizationKeySpecifier = ('actionCount' | 'description' | 'eventCount' | 'events' | 'id' | 'memberCount' | 'members' | 'mostRecentActivityTimestamp' | 'name' | 'orgAndSuborgsStatsHash' | 'orgStatsHash' | 'profileImagePath' | 'rank' | 'ranks' | 'subGroups' | 'url' | LeaderboardOrganizationKeySpecifier)[]; export type LeaderboardOrganizationFieldPolicy = { actionCount?: FieldPolicy | FieldReadFunction, @@ -1385,9 +1399,10 @@ export type LeaderboardRankFieldPolicy = { actionCount?: FieldPolicy | FieldReadFunction, rank?: FieldPolicy | FieldReadFunction }; -export type LeaderboardUserKeySpecifier = ('actionCount' | 'areaOfExpertise' | 'bio' | 'country' | 'displayName' | 'email' | 'events' | 'facebookProfile' | 'id' | 'linkedinProfile' | 'mostRecentActivityTimestamp' | 'mostRecentConflictOfInterestStatement' | 'mostRecentEvent' | 'mostRecentOrganizationId' | 'name' | 'notifications' | 'orcid' | 'organizations' | 'profileImagePath' | 'rank' | 'ranks' | 'role' | 'statsHash' | 'twitterHandle' | 'url' | 'username' | LeaderboardUserKeySpecifier)[]; +export type LeaderboardUserKeySpecifier = ('actionCount' | 'apiKeys' | 'areaOfExpertise' | 'bio' | 'country' | 'displayName' | 'email' | 'events' | 'facebookProfile' | 'id' | 'linkedinProfile' | 'mostRecentActivityTimestamp' | 'mostRecentConflictOfInterestStatement' | 'mostRecentEvent' | 'mostRecentOrganizationId' | 'name' | 'notifications' | 'orcid' | 'organizations' | 'profileImagePath' | 'rank' | 'ranks' | 'role' | 'statsHash' | 'twitterHandle' | 'url' | 'username' | LeaderboardUserKeySpecifier)[]; export type LeaderboardUserFieldPolicy = { actionCount?: FieldPolicy | FieldReadFunction, + apiKeys?: FieldPolicy | FieldReadFunction, areaOfExpertise?: FieldPolicy | FieldReadFunction, bio?: FieldPolicy | FieldReadFunction, country?: FieldPolicy | FieldReadFunction, @@ -1586,7 +1601,7 @@ export type MolecularProfileTextSegmentKeySpecifier = ('text' | MolecularProfile export type MolecularProfileTextSegmentFieldPolicy = { text?: FieldPolicy | FieldReadFunction }; -export type MutationKeySpecifier = ('acceptRevisions' | 'addComment' | 'addDisease' | 'addRemoteCitation' | 'addTherapy' | 'createFeature' | 'createFusionFeature' | 'createFusionVariant' | 'createMolecularProfile' | 'createVariant' | 'deleteComment' | 'deprecateComplexMolecularProfile' | 'deprecateFeature' | 'deprecateVariant' | 'editUser' | 'flagEntity' | 'moderateAssertion' | 'moderateEvidenceItem' | 'rejectRevisions' | 'resolveFlag' | 'submitAssertion' | 'submitEvidence' | 'submitVariantGroup' | 'subscribe' | 'suggestAssertionRevision' | 'suggestEvidenceItemRevision' | 'suggestFactorRevision' | 'suggestFactorVariantRevision' | 'suggestFusionRevision' | 'suggestFusionVariantRevision' | 'suggestGeneRevision' | 'suggestGeneVariantRevision' | 'suggestMolecularProfileRevision' | 'suggestSource' | 'suggestVariantGroupRevision' | 'unsubscribe' | 'updateCoi' | 'updateNotificationStatus' | 'updateSourceSuggestionStatus' | MutationKeySpecifier)[]; +export type MutationKeySpecifier = ('acceptRevisions' | 'addComment' | 'addDisease' | 'addRemoteCitation' | 'addTherapy' | 'createFeature' | 'createFusionFeature' | 'createFusionVariant' | 'createMolecularProfile' | 'createVariant' | 'deleteComment' | 'deprecateComplexMolecularProfile' | 'deprecateFeature' | 'deprecateVariant' | 'editUser' | 'flagEntity' | 'generateApiKey' | 'moderateAssertion' | 'moderateEvidenceItem' | 'rejectRevisions' | 'resolveFlag' | 'revokeApiKey' | 'submitAssertion' | 'submitEvidence' | 'submitVariantGroup' | 'subscribe' | 'suggestAssertionRevision' | 'suggestEvidenceItemRevision' | 'suggestFactorRevision' | 'suggestFactorVariantRevision' | 'suggestFusionRevision' | 'suggestFusionVariantRevision' | 'suggestGeneRevision' | 'suggestGeneVariantRevision' | 'suggestMolecularProfileRevision' | 'suggestSource' | 'suggestVariantGroupRevision' | 'unsubscribe' | 'updateCoi' | 'updateNotificationStatus' | 'updateSourceSuggestionStatus' | MutationKeySpecifier)[]; export type MutationFieldPolicy = { acceptRevisions?: FieldPolicy | FieldReadFunction, addComment?: FieldPolicy | FieldReadFunction, @@ -1604,10 +1619,12 @@ export type MutationFieldPolicy = { deprecateVariant?: FieldPolicy | FieldReadFunction, editUser?: FieldPolicy | FieldReadFunction, flagEntity?: FieldPolicy | FieldReadFunction, + generateApiKey?: FieldPolicy | FieldReadFunction, moderateAssertion?: FieldPolicy | FieldReadFunction, moderateEvidenceItem?: FieldPolicy | FieldReadFunction, rejectRevisions?: FieldPolicy | FieldReadFunction, resolveFlag?: FieldPolicy | FieldReadFunction, + revokeApiKey?: FieldPolicy | FieldReadFunction, submitAssertion?: FieldPolicy | FieldReadFunction, submitEvidence?: FieldPolicy | FieldReadFunction, submitVariantGroup?: FieldPolicy | FieldReadFunction, @@ -2043,6 +2060,11 @@ export type RevisionSetFieldPolicy = { revisions?: FieldPolicy | FieldReadFunction, updatedAt?: FieldPolicy | FieldReadFunction }; +export type RevokeApiKeyPayloadKeySpecifier = ('clientMutationId' | 'success' | RevokeApiKeyPayloadKeySpecifier)[]; +export type RevokeApiKeyPayloadFieldPolicy = { + clientMutationId?: FieldPolicy | FieldReadFunction, + success?: FieldPolicy | FieldReadFunction +}; export type ScalarFieldKeySpecifier = ('value' | ScalarFieldKeySpecifier)[]; export type ScalarFieldFieldPolicy = { value?: FieldPolicy | FieldReadFunction @@ -2418,8 +2440,9 @@ export type UpdateSourceSuggestionStatusPayloadFieldPolicy = { clientMutationId?: FieldPolicy | FieldReadFunction, sourceSuggestion?: FieldPolicy | FieldReadFunction }; -export type UserKeySpecifier = ('areaOfExpertise' | 'bio' | 'country' | 'displayName' | 'email' | 'events' | 'facebookProfile' | 'id' | 'linkedinProfile' | 'mostRecentActivityTimestamp' | 'mostRecentConflictOfInterestStatement' | 'mostRecentEvent' | 'mostRecentOrg' | 'mostRecentOrganizationId' | 'name' | 'notifications' | 'orcid' | 'organizations' | 'profileImagePath' | 'ranks' | 'role' | 'statsHash' | 'twitterHandle' | 'url' | 'username' | UserKeySpecifier)[]; +export type UserKeySpecifier = ('apiKeys' | 'areaOfExpertise' | 'bio' | 'country' | 'displayName' | 'email' | 'events' | 'facebookProfile' | 'id' | 'linkedinProfile' | 'mostRecentActivityTimestamp' | 'mostRecentConflictOfInterestStatement' | 'mostRecentEvent' | 'mostRecentOrg' | 'mostRecentOrganizationId' | 'name' | 'notifications' | 'orcid' | 'organizations' | 'profileImagePath' | 'ranks' | 'role' | 'statsHash' | 'twitterHandle' | 'url' | 'username' | UserKeySpecifier)[]; export type UserFieldPolicy = { + apiKeys?: FieldPolicy | FieldReadFunction, areaOfExpertise?: FieldPolicy | FieldReadFunction, bio?: FieldPolicy | FieldReadFunction, country?: FieldPolicy | FieldReadFunction, @@ -2660,6 +2683,10 @@ export type StrictTypedTypePolicies = { keyFields?: false | AdvancedSearchResultKeySpecifier | (() => undefined | AdvancedSearchResultKeySpecifier), fields?: AdvancedSearchResultFieldPolicy, }, + ApiKey?: Omit & { + keyFields?: false | ApiKeyKeySpecifier | (() => undefined | ApiKeyKeySpecifier), + fields?: ApiKeyFieldPolicy, + }, Assertion?: Omit & { keyFields?: false | AssertionKeySpecifier | (() => undefined | AssertionKeySpecifier), fields?: AssertionFieldPolicy, @@ -3132,6 +3159,10 @@ export type StrictTypedTypePolicies = { keyFields?: false | GeneVariantEdgeKeySpecifier | (() => undefined | GeneVariantEdgeKeySpecifier), fields?: GeneVariantEdgeFieldPolicy, }, + GenerateApiKeyPayload?: Omit & { + keyFields?: false | GenerateApiKeyPayloadKeySpecifier | (() => undefined | GenerateApiKeyPayloadKeySpecifier), + fields?: GenerateApiKeyPayloadFieldPolicy, + }, LeaderboardOrganization?: Omit & { keyFields?: false | LeaderboardOrganizationKeySpecifier | (() => undefined | LeaderboardOrganizationKeySpecifier), fields?: LeaderboardOrganizationFieldPolicy, @@ -3356,6 +3387,10 @@ export type StrictTypedTypePolicies = { keyFields?: false | RevisionSetKeySpecifier | (() => undefined | RevisionSetKeySpecifier), fields?: RevisionSetFieldPolicy, }, + RevokeApiKeyPayload?: Omit & { + keyFields?: false | RevokeApiKeyPayloadKeySpecifier | (() => undefined | RevokeApiKeyPayloadKeySpecifier), + fields?: RevokeApiKeyPayloadFieldPolicy, + }, ScalarField?: Omit & { keyFields?: false | ScalarFieldKeySpecifier | (() => undefined | ScalarFieldKeySpecifier), fields?: ScalarFieldFieldPolicy, diff --git a/client/src/app/generated/civic.apollo.ts b/client/src/app/generated/civic.apollo.ts index 255fe12d6..85aee9a92 100644 --- a/client/src/app/generated/civic.apollo.ts +++ b/client/src/app/generated/civic.apollo.ts @@ -129,7 +129,6 @@ export enum ActivitySubjectInput { Assertion = 'ASSERTION', Comment = 'COMMENT', EvidenceItem = 'EVIDENCE_ITEM', - ExonCoordinate = 'EXON_COORDINATE', Feature = 'FEATURE', Flag = 'FLAG', MolecularProfile = 'MOLECULAR_PROFILE', @@ -138,7 +137,6 @@ export enum ActivitySubjectInput { Source = 'SOURCE', SourceSuggestion = 'SOURCE_SUGGESTION', Variant = 'VARIANT', - VariantCoordinate = 'VARIANT_COORDINATE', VariantGroup = 'VARIANT_GROUP' } @@ -270,6 +268,14 @@ export enum AmpLevel { TierILevelB = 'TIER_I_LEVEL_B' } +export type ApiKey = { + __typename: 'ApiKey'; + createdAt?: Maybe; + id: Scalars['Int']['output']; + reminder: Scalars['String']['output']; + token?: Maybe; +}; + export enum AreaOfExpertise { ClinicalScientist = 'CLINICAL_SCIENTIST', PatientAdvocate = 'PATIENT_ADVOCATE', @@ -874,6 +880,7 @@ export type BrowseTherapyEdge = { export type BrowseUser = { __typename: 'BrowseUser'; + apiKeys: Array; areaOfExpertise?: Maybe; bio?: Maybe; country?: Maybe; @@ -3479,6 +3486,22 @@ export type GeneVariantFields = { variantTypeIds: Array; }; +/** Autogenerated input type of GenerateApiKey */ +export type GenerateApiKeyInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; +}; + +/** Autogenerated return type of GenerateApiKey. */ +export type GenerateApiKeyPayload = { + __typename: 'GenerateApiKeyPayload'; + /** The newly created API key. */ + apiKey?: Maybe; + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: Maybe; + user?: Maybe; +}; + export type IntSearchInput = { comparisonOperator: IntSearchOperator; value: Scalars['Int']['input']; @@ -3568,6 +3591,7 @@ export type LeaderboardRank = { export type LeaderboardUser = { __typename: 'LeaderboardUser'; actionCount: Scalars['Int']['output']; + apiKeys: Array; areaOfExpertise?: Maybe; bio?: Maybe; country?: Maybe; @@ -4077,6 +4101,8 @@ export type Mutation = { editUser?: Maybe; /** Flag an entity to signal to the editorial team that you believe there is an issue with it. */ flagEntity?: Maybe; + /** Generate a new API key for the current user. */ + generateApiKey?: Maybe; /** Perform moderation actions on an assertion such as accepting, rejecting, or reverting. */ moderateAssertion?: Maybe; /** Perform moderation actions on an evidence item such as accepting, rejecting, or reverting. */ @@ -4093,6 +4119,8 @@ export type Mutation = { * of interest statements can resolve other flags. */ resolveFlag?: Maybe; + /** Revoke an API key for the current user. */ + revokeApiKey?: Maybe; /** Propose adding a new Assertion to the CIViC database. */ submitAssertion?: Maybe; /** Propose adding a new EvidenceItem to the CIViC database. */ @@ -4214,6 +4242,11 @@ export type MutationFlagEntityArgs = { }; +export type MutationGenerateApiKeyArgs = { + input: GenerateApiKeyInput; +}; + + export type MutationModerateAssertionArgs = { input: ModerateAssertionInput; }; @@ -4234,6 +4267,11 @@ export type MutationResolveFlagArgs = { }; +export type MutationRevokeApiKeyArgs = { + input: RevokeApiKeyInput; +}; + + export type MutationSubmitAssertionArgs = { input: SubmitAssertionInput; }; @@ -5899,6 +5937,23 @@ export enum RevisionStatus { Superseded = 'SUPERSEDED' } +/** Autogenerated input type of RevokeApiKey */ +export type RevokeApiKeyInput = { + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: InputMaybe; + /** ID of the API key to be revoked. */ + id: Scalars['Int']['input']; +}; + +/** Autogenerated return type of RevokeApiKey. */ +export type RevokeApiKeyPayload = { + __typename: 'RevokeApiKeyPayload'; + /** A unique identifier for the client performing the mutation. */ + clientMutationId?: Maybe; + /** Indicates whether the API key was successfully revoked. */ + success: Scalars['Boolean']['output']; +}; + export type ScalarField = { __typename: 'ScalarField'; value?: Maybe; @@ -7055,6 +7110,7 @@ export type UpdateSourceSuggestionStatusPayload = { export type User = { __typename: 'User'; + apiKeys: Array; areaOfExpertise?: Maybe; bio?: Maybe; country?: Maybe; @@ -8896,6 +8952,25 @@ export type UpdateSourceSuggestionMutationVariables = Exact<{ export type UpdateSourceSuggestionMutation = { __typename: 'Mutation', updateSourceSuggestionStatus?: { __typename: 'UpdateSourceSuggestionStatusPayload', sourceSuggestion: { __typename: 'SourceSuggestion', id: number, status: SourceSuggestionStatus } } | undefined }; +export type GenerateApiKeyMutationVariables = Exact<{ + input: GenerateApiKeyInput; +}>; + + +export type GenerateApiKeyMutation = { __typename: 'Mutation', generateApiKey?: { __typename: 'GenerateApiKeyPayload', apiKey?: { __typename: 'ApiKey', id: number, reminder: string, createdAt?: any | undefined, token?: string | undefined } | undefined } | undefined }; + +export type RevokeApiKeyMutationVariables = Exact<{ + input: RevokeApiKeyInput; +}>; + + +export type RevokeApiKeyMutation = { __typename: 'Mutation', revokeApiKey?: { __typename: 'RevokeApiKeyPayload', success: boolean } | undefined }; + +export type ApiKeysQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ApiKeysQuery = { __typename: 'Query', viewer?: { __typename: 'User', apiKeys: Array<{ __typename: 'ApiKey', id: number, reminder: string, createdAt?: any | undefined, token?: string | undefined }> } | undefined }; + export type UpdateCoiMutationVariables = Exact<{ input: UpdateCoiInput; }>; @@ -16098,6 +16173,70 @@ export const UpdateSourceSuggestionDocument = gql` export class UpdateSourceSuggestionGQL extends Apollo.Mutation { document = UpdateSourceSuggestionDocument; + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GenerateApiKeyDocument = gql` + mutation GenerateApiKey($input: GenerateApiKeyInput!) { + generateApiKey(input: $input) { + apiKey { + id + reminder + createdAt + token + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GenerateApiKeyGQL extends Apollo.Mutation { + document = GenerateApiKeyDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const RevokeApiKeyDocument = gql` + mutation RevokeApiKey($input: RevokeApiKeyInput!) { + revokeApiKey(input: $input) { + success + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class RevokeApiKeyGQL extends Apollo.Mutation { + document = RevokeApiKeyDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const ApiKeysDocument = gql` + query ApiKeys { + viewer { + apiKeys { + id + reminder + createdAt + token + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class ApiKeysGQL extends Apollo.Query { + document = ApiKeysDocument; + constructor(apollo: Apollo.Apollo) { super(apollo); } diff --git a/client/src/app/generated/server.model.graphql b/client/src/app/generated/server.model.graphql index 18840fbe5..89c4b1bef 100644 --- a/client/src/app/generated/server.model.graphql +++ b/client/src/app/generated/server.model.graphql @@ -371,6 +371,13 @@ enum AmpLevel { TIER_I_LEVEL_B } +type ApiKey { + createdAt: ISO8601DateTime + id: Int! + reminder: String! + token: String +} + enum AreaOfExpertise { CLINICAL_SCIENTIST PATIENT_ADVOCATE @@ -1427,6 +1434,7 @@ type BrowseTherapyEdge { } type BrowseUser { + apiKeys: [ApiKey!]! areaOfExpertise: AreaOfExpertise bio: String country: Country @@ -6171,6 +6179,32 @@ input GeneVariantFields { variantTypeIds: [Int!]! } +""" +Autogenerated input type of GenerateApiKey +""" +input GenerateApiKeyInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String +} + +""" +Autogenerated return type of GenerateApiKey. +""" +type GenerateApiKeyPayload { + """ + The newly created API key. + """ + apiKey: ApiKey + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + user: User +} + """ An ISO 8601-encoded datetime """ @@ -6309,6 +6343,7 @@ type LeaderboardRank { type LeaderboardUser { actionCount: Int! + apiKeys: [ApiKey!]! areaOfExpertise: AreaOfExpertise bio: String country: Country @@ -7240,6 +7275,16 @@ type Mutation { input: FlagEntityInput! ): FlagEntityPayload + """ + Generate a new API key for the current user. + """ + generateApiKey( + """ + Parameters for GenerateApiKey + """ + input: GenerateApiKeyInput! + ): GenerateApiKeyPayload + """ Perform moderation actions on an assertion such as accepting, rejecting, or reverting. """ @@ -7284,6 +7329,16 @@ type Mutation { input: ResolveFlagInput! ): ResolveFlagPayload + """ + Revoke an API key for the current user. + """ + revokeApiKey( + """ + Parameters for RevokeApiKey + """ + input: RevokeApiKeyInput! + ): RevokeApiKeyPayload + """ Propose adding a new Assertion to the CIViC database. """ @@ -10159,6 +10214,36 @@ enum RevisionStatus { SUPERSEDED } +""" +Autogenerated input type of RevokeApiKey +""" +input RevokeApiKeyInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + ID of the API key to be revoked. + """ + id: Int! +} + +""" +Autogenerated return type of RevokeApiKey. +""" +type RevokeApiKeyPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Indicates whether the API key was successfully revoked. + """ + success: Boolean! +} + type ScalarField { value: String } @@ -11879,6 +11964,7 @@ type UpdateSourceSuggestionStatusPayload { } type User { + apiKeys: [ApiKey!]! areaOfExpertise: AreaOfExpertise bio: String country: Country diff --git a/client/src/app/generated/server.schema.json b/client/src/app/generated/server.schema.json index be7d148b1..55789175a 100644 --- a/client/src/app/generated/server.schema.json +++ b/client/src/app/generated/server.schema.json @@ -1728,6 +1728,73 @@ ], "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "ApiKey", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reminder", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "token", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "ENUM", "name": "AreaOfExpertise", @@ -6821,6 +6888,30 @@ "name": "BrowseUser", "description": null, "fields": [ + { + "name": "apiKeys", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ApiKey", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "areaOfExpertise", "description": null, @@ -28420,6 +28511,76 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "GenerateApiKeyInput", + "description": "Autogenerated input type of GenerateApiKey", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GenerateApiKeyPayload", + "description": "Autogenerated return type of GenerateApiKey.", + "fields": [ + { + "name": "apiKey", + "description": "The newly created API key.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "ApiKey", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "User", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "SCALAR", "name": "ID", @@ -29138,6 +29299,30 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "apiKeys", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ApiKey", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "areaOfExpertise", "description": null, @@ -33361,6 +33546,35 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "generateApiKey", + "description": "Generate a new API key for the current user.", + "args": [ + { + "name": "input", + "description": "Parameters for GenerateApiKey", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "GenerateApiKeyInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "GenerateApiKeyPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "moderateAssertion", "description": "Perform moderation actions on an assertion such as accepting, rejecting, or reverting.", @@ -33477,6 +33691,35 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "revokeApiKey", + "description": "Revoke an API key for the current user.", + "args": [ + { + "name": "input", + "description": "Parameters for RevokeApiKey", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RevokeApiKeyInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "RevokeApiKeyPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "submitAssertion", "description": "Propose adding a new Assertion to the CIViC database.", @@ -46252,6 +46495,84 @@ ], "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "RevokeApiKeyInput", + "description": "Autogenerated input type of RevokeApiKey", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID of the API key to be revoked.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "RevokeApiKeyPayload", + "description": "Autogenerated return type of RevokeApiKey.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "success", + "description": "Indicates whether the API key was successfully revoked.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "ScalarField", @@ -53427,6 +53748,30 @@ "name": "User", "description": null, "fields": [ + { + "name": "apiKeys", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ApiKey", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "areaOfExpertise", "description": null, diff --git a/client/src/app/views/users/users-detail/users-detail.component.html b/client/src/app/views/users/users-detail/users-detail.component.html index fe2d624f0..e944f5258 100644 --- a/client/src/app/views/users/users-detail/users-detail.component.html +++ b/client/src/app/views/users/users-detail/users-detail.component.html @@ -143,6 +143,15 @@ *nzSpaceItem (uploadComplete)="profileUploadComplete($event)"> + @@ -274,7 +283,7 @@ nzTitle="Updated"> {{ user.mostRecentConflictOfInterestStatement.createdAt - | date : 'shortDate' + | date: 'shortDate' }} {{ user.mostRecentConflictOfInterestStatement.expiresAt - | date : 'shortDate' + | date: 'shortDate' }} - + + Manage API Keys + + + + + "Bearer #{@api_key.token}" } + response_data = JSON.parse(@response.body).dig("data", "viewer") + assert_equal response_data["id"], @user1.id + assert_equal response_data["username"], @user1.username + end + + test "getting the viewer with a revoked bearer token" do + post "/api/graphql", params: { query: @query_string }, headers: { "Authorization" => "Bearer #{@revoked_api_key.token}" } + assert_nil JSON.parse(@response.body).dig("data", "viewer") + end + + test "adding a comment without a bearer token" do + post "/api/graphql", params: { query: @mutation_string } + assert_match (/You must log in to perform this mutation/), JSON.parse(@response.body).dig("errors", 0, "message") + end + + test "adding a comment with a valid bearer token" do + post "/api/graphql", params: { query: @mutation_string }, headers: { "Authorization" => "Bearer #{@api_key.token}" } + response_data = JSON.parse(@response.body).dig("data", "addComment", "comment") + assert_not_nil response_data + assert_equal response_data["comment"], "This is a test comment" + end +end diff --git a/server/test/graphql/mutations/generate_and_revoke_api_keys_test.rb b/server/test/graphql/mutations/generate_and_revoke_api_keys_test.rb new file mode 100644 index 000000000..4a135465f --- /dev/null +++ b/server/test/graphql/mutations/generate_and_revoke_api_keys_test.rb @@ -0,0 +1,77 @@ +class GenerateAndRevokeApiKeysTest < ActiveSupport::TestCase + def setup + @user = users(:curator) + @generate_api_key_mutation = <<-GRAPHQL + mutation { + generateApiKey(input: {}) { + apiKey { + id + token + } + user { + id + apiKeys { + id + token + } + } + } + } + GRAPHQL + + @revoke_api_key_mutation = <<-GRAPHQL + mutation($id: Int!) { + revokeApiKey(input: { id: $id }) { + success + } + } + GRAPHQL + + @get_api_keys_query = <<-GRAPHQL + query { + viewer { + apiKeys { + id + reminder + } + } + } + GRAPHQL + end + + test "calling the generate api key mutation results in two associated api keys" do + # Generate the first API key + response = Civic2Schema.execute(@generate_api_key_mutation, context: { current_user: @user }) + assert response.dig("data", "generateApiKey", "apiKey").present? + + # Generate the second API key + response = Civic2Schema.execute(@generate_api_key_mutation, context: { current_user: @user }) + assert response.dig("data", "generateApiKey", "apiKey").present? + + # Fetch the user's API keys + response = Civic2Schema.execute(@get_api_keys_query, context: { current_user: @user }) + api_keys = response.dig("data", "viewer", "apiKeys") + assert_equal 2, api_keys.size + end + + test "calling the revoke api key mutation sets the status of the api key to revoked" do + # Generate an API key + response = Civic2Schema.execute(@generate_api_key_mutation, context: { current_user: @user }) + api_key_id = response.dig("data", "generateApiKey", "apiKey", "id") + assert api_key_id.present? + + # Revoke the API key + response = Civic2Schema.execute(@revoke_api_key_mutation, variables: { id: api_key_id }, context: { current_user: @user }) + assert response.dig("data", "revokeApiKey", "success") + + # Fetch the user's API keys + response = Civic2Schema.execute(@get_api_keys_query, context: { current_user: @user }) + api_keys = response.dig("data", "viewer", "apiKeys") + revoked_key = api_keys.find { |key| key["id"] == api_key_id } + assert_nil revoked_key + + # Verify the API key status in the database + api_key = ApiKey.find(api_key_id) + assert api_key.revoked + end +end