Compare commits

...

9 Commits

Author SHA1 Message Date
Jeff McCune
90f8eab816 (#144) Tidy go.mod and package.json 2024-04-25 19:14:20 -07:00
Jeff McCune
aee15f95e2 Merge pull request #145 from holos-run/jeff/144-organization-selector
(#144) Profile Button
2024-04-25 09:55:55 -07:00
Jeff McCune
1c540ac375 (#144) Profile Button and Organization Selector
This patch adds an organization "selector" that's really just a place
holder.  The active organization is the last element in the list
returned by the GetCallerOrganizations method for now.

The purpose is to make sure we have the structure in place for more than
one organizations without needing to implement full support for the
feature at this early stage.

The Angular frontend is expected to call the activeOrg() method of the
OrganizationService.  In the future this could store the state of which
organization the user has selected.  The purpose is to return an org id
to send as a request parameter for other requests.

Note this patch also implements refresh behavior.  The list of orgs is
fetched once on application load.  If there is no user, or the user has
zero orgs, the user is created and an organization is added with them as
an owner.  This is accompished using observable pipes.

The pipe is tied to a refresh behavior.  Clicking the org button
triggers the refresh behavior, which executes the pipe again and
notifies all subscribers.

This works quite well and should be idiomatic angular / rxjs.  Clicking
the button automatically updates the UI after making the necessary API
calls.
2024-04-25 09:55:13 -07:00
Jeff McCune
5b0e883ac9 (#144) Get or Create the orgranization
This patch adds the OrganizationService to the Angular front end and
displays a simple list of the organizations the user is a member of in
the profile card.

There isn't a service yet to return the currently selected
organization, but that could be a simple method to return the most
recent entry in the list until we put something more complicated in
place like local storage of what the user has selected.

It may make sense to put a database constraint on the number of
organizations until we implement the feature later, it's too early to do
so now, I just want to make sure it's possible to add later.
2024-04-25 07:02:17 -07:00
Jeff McCune
9a2519af71 (#144) Make the linter happy 2024-04-24 13:41:45 -07:00
Jeff McCune
9b9ff601c0 (#144) Call GetCallerClaims once instead of multiple times
Problem:
When loading the page the GetCallerClaims rpc method is called multiple
times unnecessarily.

Solution:
Use [shareReplay][1] to replay the last observable event for all
subscribers, including subscribers coming late to the party.

Result:
Network inspector in chrome indicates GetCallerClaims is called once and
only once.

[1]: https://rxjs.dev/api/operators/shareReplay
2024-04-24 12:44:44 -07:00
Jeff McCune
2f798296dc (#144) Profile Button
This patch adds a ProfileButton component which makes a ConnectRPC gRPC
call to the `holos.v1alpha1.UserService.GetCallerClaims` method and
renders the profile button based on the claims.

Note, in the network inspector there are two API calls to
`holos.v1alpha1.UserService.GetCallerClaims` which is unfortunate.  A
follow up patch might be good to fix this.
2024-04-24 12:23:54 -07:00
Jeff McCune
2b2ff63cad (#144) Connect /ui to ng serve for hot reload
Problem:
It's slow to build the angular app, compile it into the go executable,
copy it to the pod, then restart the server.

Solution:
Configure the mesh to route /ui to `ng serve` running on my local
host.

Result:
Navigating to https://jeff.app.dev.k2.holos.run/ui gets responses from
the ng development server.

Use:

    ng serve --host 0.0.0.0
2024-04-23 20:30:02 -07:00
Jeff McCune
3b135c09f3 (#144) Make a ConnectRPC call to the GetUserClaims method
This patch wires up an Angular RxJS Observable to the result of a gRPC
call to the `holos.v1alpha1.UserService.GetCallerClaims` method.

The implementation is a combination of [this connect example][1] and the
official [angular data][2] guide.

[1]: https://github.com/connectrpc/examples-es/tree/main/angular
[2]: https://angular.io/start/start-data#configuring-the-shippingcomponent-to-use-cartservice
2024-04-23 17:18:35 -07:00
38 changed files with 665 additions and 166 deletions

View File

@@ -11,14 +11,14 @@ plugins:
out: service/gen
opt: paths=source_relative
- plugin: es
out: internal/frontend/holos/gen
out: internal/frontend/holos/src/app/gen
opt:
- target=ts
- plugin: connect-es
out: internal/frontend/holos/gen
out: internal/frontend/holos/src/app/gen
opt:
- target=ts
- plugin: connect-query
out: internal/frontend/holos/gen
out: internal/frontend/holos/src/app/gen
opt:
- target=ts

2
go.mod
View File

@@ -5,6 +5,7 @@ go 1.21.5
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.33.0-20240401165935-b983156c5e99.1
connectrpc.com/connect v1.16.0
connectrpc.com/grpcreflect v1.2.0
connectrpc.com/validate v0.1.0
cuelang.org/go v0.8.0
entgo.io/ent v0.13.1
@@ -42,7 +43,6 @@ require (
ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect
cloud.google.com/go/compute v1.23.3 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
connectrpc.com/grpcreflect v1.2.0 // indirect
connectrpc.com/otelconnect v0.7.0 // indirect
cuelabs.dev/go/oci/ociregistry v0.0.0-20240314152124-224736b49f2e // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect

View File

@@ -103,13 +103,42 @@ spec:
hosts:
- '{developer}.app.dev.k2.holos.run'
http:
- route:
- name: "coffee-ui"
match:
- uri:
prefix: "/ui"
route:
- destination:
host: coffee
port:
number: 4200
- name: "holos-api"
route:
- destination:
host: '{name}'
port:
number: {listen_port}
---
apiVersion: v1
kind: Service
metadata:
name: coffee
spec:
ports:
- protocol: TCP
port: 4200
---
apiVersion: v1
kind: Endpoints
metadata:
name: coffee
subsets:
- addresses:
- ip: 192.168.2.21
ports:
- port: 4200
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: holos

View File

@@ -18,7 +18,7 @@
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"@bufbuild/protobuf": "^1.8.0",
"@bufbuild/protobuf": "^1.9.0",
"@connectrpc/connect": "^1.4.0",
"@connectrpc/connect-query": "^1.3.1",
"@connectrpc/connect-web": "^1.4.0",
@@ -30,8 +30,8 @@
"@angular-devkit/build-angular": "^17.3.4",
"@angular/cli": "^17.3.4",
"@angular/compiler-cli": "^17.3.0",
"@bufbuild/buf": "^1.30.1",
"@bufbuild/protoc-gen-es": "^1.8.0",
"@bufbuild/buf": "^1.31.0",
"@bufbuild/protoc-gen-es": "^1.9.0",
"@connectrpc/protoc-gen-connect-es": "^1.4.0",
"@connectrpc/protoc-gen-connect-query": "^1.3.1",
"@types/jasmine": "~5.1.0",
@@ -2337,9 +2337,9 @@
}
},
"node_modules/@bufbuild/buf": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.30.1.tgz",
"integrity": "sha512-9VVvrXBCWUiH8ToccqDfPRuTiPXSbHmSkL8XPlMpUhpJIlm01m4/Vzbc5FJL1yuk3e1rdBGCF6I9Obs9NsILzg==",
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.31.0.tgz",
"integrity": "sha512-kM/eueGkp0NDo8p8B6GXr1MdCzf4w8zEV1gbEiDlaLYDoyeHGLtlf5jF/hrb6MsvCccy3x7cc+cj4Wn/DmoR2g==",
"dev": true,
"hasInstallScript": true,
"bin": {
@@ -2351,18 +2351,18 @@
"node": ">=12"
},
"optionalDependencies": {
"@bufbuild/buf-darwin-arm64": "1.30.1",
"@bufbuild/buf-darwin-x64": "1.30.1",
"@bufbuild/buf-linux-aarch64": "1.30.1",
"@bufbuild/buf-linux-x64": "1.30.1",
"@bufbuild/buf-win32-arm64": "1.30.1",
"@bufbuild/buf-win32-x64": "1.30.1"
"@bufbuild/buf-darwin-arm64": "1.31.0",
"@bufbuild/buf-darwin-x64": "1.31.0",
"@bufbuild/buf-linux-aarch64": "1.31.0",
"@bufbuild/buf-linux-x64": "1.31.0",
"@bufbuild/buf-win32-arm64": "1.31.0",
"@bufbuild/buf-win32-x64": "1.31.0"
}
},
"node_modules/@bufbuild/buf-darwin-arm64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.30.1.tgz",
"integrity": "sha512-FRgf+x4V4s9Z1wH2xHdP8+1AYtil1GCmMjzKf/4AQ+eaUpoLfipSIsVYiBrnpcRxEPe9UMVzwNjKtPak/szwPw==",
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.31.0.tgz",
"integrity": "sha512-C0jArGS/SW0jfpBBmG6xEhUBWQTsGInnPr7y44WYWNS/U5OnnWPJtYQ7xbH0mzYDMx7sZVRV0FKvPO0FPKS+hA==",
"cpu": [
"arm64"
],
@@ -2376,9 +2376,9 @@
}
},
"node_modules/@bufbuild/buf-darwin-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.30.1.tgz",
"integrity": "sha512-kE0ne45zE7lSdv9WxPVhapwu627WMbWmWCzqSxzYr8sWDLqiAuw+XvO9/mHGdPWcMhV4lMX6tutitd9PPVxK8A==",
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.31.0.tgz",
"integrity": "sha512-oBTe1T4l2WSukAG+9YS7VHID4N1CuSvAxGBfuzpFQrjjiQZaaTfYuLqqVP6408MyCN7X/LOjfCekR1QToVweNw==",
"cpu": [
"x64"
],
@@ -2392,9 +2392,9 @@
}
},
"node_modules/@bufbuild/buf-linux-aarch64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.30.1.tgz",
"integrity": "sha512-kVV9Sl0GwZiQkMOXJiuwuU+gIHe6AWcYBMRMmuW55sY0ePZNXBmRGt4k5W4ijy98O6pnY3ao+n9ne0KwiD9MVA==",
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.31.0.tgz",
"integrity": "sha512-UN9KsTuO9YS5Vefj/CaqX1wO+hvc3AyGElxzOHMc7S3MWEuqSAFOhxu5I7CyOr2/yoZO2qZPPR29HuzmQsb2+w==",
"cpu": [
"arm64"
],
@@ -2408,9 +2408,9 @@
}
},
"node_modules/@bufbuild/buf-linux-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.30.1.tgz",
"integrity": "sha512-RacDbQJYNwqRlMESa/rLHprfUVa8Wu1/cmcqS29Fyt/cGzs0G8sNcQzQ87HYFIS9cSlSPl6vWL0x8JqQRp68lQ==",
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.31.0.tgz",
"integrity": "sha512-4uKq5Iu5tNEFE3Mz9a52KFCaywg5xLqwhN6Kf4kAk34kxWJgQ8D3WFe9ZpXHzH7Lj00u3Q+V/3vKCVATHR3tkw==",
"cpu": [
"x64"
],
@@ -2424,9 +2424,9 @@
}
},
"node_modules/@bufbuild/buf-win32-arm64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.30.1.tgz",
"integrity": "sha512-ndp/qb5M6yrSzcnMI0j4jjAuDKa7zHBFc187FwyDb3v63rvyQeYqncHb0leT5ZWqfNggJT4vXIH6QnH82PfDQw==",
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.31.0.tgz",
"integrity": "sha512-jz7GenlNsqwbC3qaHcBHBO35MycZ1gV8OUSRp/wTXGgZsEZzAyw335JA2NWL+5LaI8cF+CsYd6/uuWzKkdCKTQ==",
"cpu": [
"arm64"
],
@@ -2440,9 +2440,9 @@
}
},
"node_modules/@bufbuild/buf-win32-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.30.1.tgz",
"integrity": "sha512-1kmIY6oKLKZ4zIQVNG60GRDp+vKSZdaim7wRejOtgEDuWXhIuErlnGbpstypU8FO+OV3SeFUJNOJ8tLOYd3PvQ==",
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.31.0.tgz",
"integrity": "sha512-gw7p3PYam0g7hNwIhNphua5P8GBZczginoWNK3jk5sGVv0TzWOdHrjkVVhkc3DJbRZEg10ExGI3qRfCqiX1IHw==",
"cpu": [
"x64"
],
@@ -2456,18 +2456,18 @@
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.8.0.tgz",
"integrity": "sha512-qR9FwI8QKIveDnUYutvfzbC21UZJJryYrLuZGjeZ/VGz+vXelUkK+xgkOHsvPEdYEdxtgUUq4313N8QtOehJ1Q=="
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.9.0.tgz",
"integrity": "sha512-W7gp8Q/v1NlCZLsv8pQ3Y0uCu/SHgXOVFK+eUluUKWXmsb6VHkpNx0apdOWWcDbB9sJoKeP8uPrjmehJz6xETQ=="
},
"node_modules/@bufbuild/protoc-gen-es": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-1.8.0.tgz",
"integrity": "sha512-jnvBKwHq3o/iOgfKxaxn5Za7ay4oAs8KWgoHiDc9Fsb0g+/d1z+mHlHvmevOiCPcVZsnH6V3LImOJvGStPONpA==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-1.9.0.tgz",
"integrity": "sha512-LJy1nC3Jsfdhs9v48P7qF6YXIqh+usFhXSVzJDTmw0yKjxQ3CKBNISRtaMql/g9hb1MLRU6unHCcFfdz4HSO/Q==",
"dev": true,
"dependencies": {
"@bufbuild/protobuf": "^1.8.0",
"@bufbuild/protoplugin": "1.8.0"
"@bufbuild/protobuf": "^1.9.0",
"@bufbuild/protoplugin": "1.9.0"
},
"bin": {
"protoc-gen-es": "bin/protoc-gen-es"
@@ -2476,7 +2476,7 @@
"node": ">=14"
},
"peerDependencies": {
"@bufbuild/protobuf": "1.8.0"
"@bufbuild/protobuf": "1.9.0"
},
"peerDependenciesMeta": {
"@bufbuild/protobuf": {
@@ -2485,12 +2485,12 @@
}
},
"node_modules/@bufbuild/protoplugin": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-1.8.0.tgz",
"integrity": "sha512-Pb89cTshW+I577qh27VvxGYvZEvQ3zJ8La1OfzPCKugP9d4A4P65WStkAY+aSCiDHk68m1/+mtBb6elfiLPuFg==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-1.9.0.tgz",
"integrity": "sha512-/mxMiGs5h78RUHT7v4+mv0Wt0gyRf/SOS5PLzKEg2sclEAlFPbXfZ8HjlvxJpXZP/YpP3HvsW/mil3E69G0mXg==",
"dev": true,
"dependencies": {
"@bufbuild/protobuf": "1.8.0",
"@bufbuild/protobuf": "1.9.0",
"@typescript/vfs": "^1.4.0",
"typescript": "4.5.2"
}

View File

@@ -20,7 +20,7 @@
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"@bufbuild/protobuf": "^1.8.0",
"@bufbuild/protobuf": "^1.9.0",
"@connectrpc/connect": "^1.4.0",
"@connectrpc/connect-query": "^1.3.1",
"@connectrpc/connect-web": "^1.4.0",
@@ -32,8 +32,8 @@
"@angular-devkit/build-angular": "^17.3.4",
"@angular/cli": "^17.3.4",
"@angular/compiler-cli": "^17.3.0",
"@bufbuild/buf": "^1.30.1",
"@bufbuild/protoc-gen-es": "^1.8.0",
"@bufbuild/buf": "^1.31.0",
"@bufbuild/protoc-gen-es": "^1.9.0",
"@connectrpc/protoc-gen-connect-es": "^1.4.0",
"@connectrpc/protoc-gen-connect-query": "^1.3.1",
"@types/jasmine": "~5.1.0",
@@ -45,4 +45,4 @@
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.4.2"
}
}
}

View File

@@ -1,9 +1,25 @@
import { ApplicationConfig } from '@angular/core';
import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { provideRouter } from '@angular/router';
// import { provideHttpClient, withFetch } from '@angular/common/http';
import { routes } from './app.routes';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { ConnectModule } from '../connect/connect.module';
import { provideClient } from "../connect/client.provider";
import { UserService } from './gen/holos/v1alpha1/user_connect';
import { OrganizationService } from './gen/holos/v1alpha1/organization_connect';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), provideAnimationsAsync()]
providers: [
provideRouter(routes),
provideAnimationsAsync(),
// provideHttpClient(withFetch()),
provideClient(UserService),
provideClient(OrganizationService),
importProvidersFrom(
ConnectModule.forRoot({
baseUrl: window.location.origin
}),
),
]
};

View File

@@ -4,7 +4,7 @@
// @ts-nocheck
import { MethodKind } from "@bufbuild/protobuf";
import { CreateCallerOrganizationRequest, CreateCallerOrganizationResponse, GetCallerOrganizationsRequest, GetCallerOrganizationsResponse } from "./organization_pb.js";
import { CreateCallerOrganizationRequest, GetCallerOrganizationsRequest, GetCallerOrganizationsResponse } from "./organization_pb.js";
/**
* @generated from rpc holos.v1alpha1.OrganizationService.GetCallerOrganizations
@@ -28,7 +28,7 @@ export const createCallerOrganization = {
name: "CreateCallerOrganization",
kind: MethodKind.Unary,
I: CreateCallerOrganizationRequest,
O: CreateCallerOrganizationResponse,
O: GetCallerOrganizationsResponse,
service: {
typeName: "holos.v1alpha1.OrganizationService"
}

View File

@@ -3,7 +3,7 @@
/* eslint-disable */
// @ts-nocheck
import { CreateCallerOrganizationRequest, CreateCallerOrganizationResponse, GetCallerOrganizationsRequest, GetCallerOrganizationsResponse } from "./organization_pb.js";
import { CreateCallerOrganizationRequest, GetCallerOrganizationsRequest, GetCallerOrganizationsResponse } from "./organization_pb.js";
import { MethodKind } from "@bufbuild/protobuf";
/**
@@ -27,7 +27,7 @@ export const OrganizationService = {
createCallerOrganization: {
name: "CreateCallerOrganization",
I: CreateCallerOrganizationRequest,
O: CreateCallerOrganizationResponse,
O: GetCallerOrganizationsResponse,
kind: MethodKind.Unary,
},
}

View File

@@ -1,4 +1,4 @@
// @generated by protoc-gen-es v1.8.0 with parameter "target=ts"
// @generated by protoc-gen-es v1.9.0 with parameter "target=ts"
// @generated from file holos/v1alpha1/organization.proto (package holos.v1alpha1, syntax proto3)
/* eslint-disable */
// @ts-nocheck
@@ -170,46 +170,3 @@ export class CreateCallerOrganizationRequest extends Message<CreateCallerOrganiz
}
}
/**
* @generated from message holos.v1alpha1.CreateCallerOrganizationResponse
*/
export class CreateCallerOrganizationResponse extends Message<CreateCallerOrganizationResponse> {
/**
* @generated from field: holos.v1alpha1.User user = 1;
*/
user?: User;
/**
* @generated from field: repeated holos.v1alpha1.Organization organizations = 2;
*/
organizations: Organization[] = [];
constructor(data?: PartialMessage<CreateCallerOrganizationResponse>) {
super();
proto3.util.initPartial(data, this);
}
static readonly runtime: typeof proto3 = proto3;
static readonly typeName = "holos.v1alpha1.CreateCallerOrganizationResponse";
static readonly fields: FieldList = proto3.util.newFieldList(() => [
{ no: 1, name: "user", kind: "message", T: User },
{ no: 2, name: "organizations", kind: "message", T: Organization, repeated: true },
]);
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): CreateCallerOrganizationResponse {
return new CreateCallerOrganizationResponse().fromBinary(bytes, options);
}
static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): CreateCallerOrganizationResponse {
return new CreateCallerOrganizationResponse().fromJson(jsonValue, options);
}
static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): CreateCallerOrganizationResponse {
return new CreateCallerOrganizationResponse().fromJsonString(jsonString, options);
}
static equals(a: CreateCallerOrganizationResponse | PlainMessage<CreateCallerOrganizationResponse> | undefined, b: CreateCallerOrganizationResponse | PlainMessage<CreateCallerOrganizationResponse> | undefined): boolean {
return proto3.util.equals(CreateCallerOrganizationResponse, a, b);
}
}

View File

@@ -1,4 +1,4 @@
// @generated by protoc-gen-es v1.8.0 with parameter "target=ts"
// @generated by protoc-gen-es v1.9.0 with parameter "target=ts"
// @generated from file holos/v1alpha1/timestamps.proto (package holos.v1alpha1, syntax proto3)
/* eslint-disable */
// @ts-nocheck

View File

@@ -1,4 +1,4 @@
// @generated by protoc-gen-es v1.8.0 with parameter "target=ts"
// @generated by protoc-gen-es v1.9.0 with parameter "target=ts"
// @generated from file holos/v1alpha1/user.proto (package holos.v1alpha1, syntax proto3)
/* eslint-disable */
// @ts-nocheck
@@ -205,6 +205,21 @@ export class Claims extends Message<Claims> {
*/
groups: string[] = [];
/**
* @generated from field: string given_name = 7;
*/
givenName = "";
/**
* @generated from field: string family_name = 8;
*/
familyName = "";
/**
* @generated from field: string picture = 9;
*/
picture = "";
constructor(data?: PartialMessage<Claims>) {
super();
proto3.util.initPartial(data, this);
@@ -219,6 +234,9 @@ export class Claims extends Message<Claims> {
{ no: 4, name: "email_verified", kind: "scalar", T: 8 /* ScalarType.BOOL */ },
{ no: 5, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 6, name: "groups", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true },
{ no: 7, name: "given_name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 8, name: "family_name", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 9, name: "picture", kind: "scalar", T: 9 /* ScalarType.STRING */ },
]);
static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): Claims {

View File

@@ -3,7 +3,9 @@
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
[mode]="(isHandset$ | async) ? 'over' : 'side'"
[opened]="(isHandset$ | async) === false">
<mat-toolbar>Menu</mat-toolbar>
<mat-toolbar>
<span>Menu</span>
</mat-toolbar>
<mat-nav-list>
<a mat-list-item routerLink="/home" routerLinkActive="active-link">Home</a>
<a mat-list-item routerLink="/clusters" routerLinkActive="active-link">Clusters</a>
@@ -21,6 +23,15 @@
</button>
}
<span>Holos</span>
<span class="toolbar-spacer"></span>
<span>
@if (org$ | async; as org) {
<button mat-button (click)="refreshOrg()">
{{ org.displayName }}
</button>
}
</span>
<app-profile-button [claims$]="claims$"></app-profile-button>
</mat-toolbar>
<!-- Add Content Here -->
<router-outlet></router-outlet>

View File

@@ -15,3 +15,22 @@
top: 0;
z-index: 1;
}
.toolbar-spacer {
flex: 1 1 auto;
}
.avatar-container {
height: 100%;
width: 100%;
border-radius: 50%;
}
button {
&.image {
background-color: transparent;
background-repeat: no-repeat;
background-size: cover;
background-position: center center;
}
}

View File

@@ -1,6 +1,6 @@
import { Component, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { AsyncPipe } from '@angular/common';
import { AsyncPipe, NgIf } from '@angular/common';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatSidenavModule } from '@angular/material/sidenav';
@@ -9,6 +9,12 @@ import { MatIconModule } from '@angular/material/icon';
import { Observable } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { Claims } from '../gen/holos/v1alpha1/user_pb';
import { ProfileButtonComponent } from '../profile-button/profile-button.component';
import { UserService } from '../services/user.service';
import { Organization } from '../gen/holos/v1alpha1/organization_pb';
import { OrganizationService } from '../services/organization.service';
@Component({
selector: 'app-nav',
@@ -21,18 +27,35 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
MatSidenavModule,
MatListModule,
MatIconModule,
NgIf,
AsyncPipe,
RouterLink,
RouterLinkActive,
RouterOutlet,
MatCardModule,
ProfileButtonComponent,
]
})
export class NavComponent {
export class NavComponent implements OnInit {
private breakpointObserver = inject(BreakpointObserver);
private userService = inject(UserService);
private orgService = inject(OrganizationService);
claims$!: Observable<Claims | null>;
org$!: Observable<Organization | undefined>;
refreshOrg(): void {
this.orgService.refreshOrganizations()
}
isHandset$: Observable<boolean> = this.breakpointObserver.observe(Breakpoints.Handset)
.pipe(
map(result => result.matches),
shareReplay()
);
ngOnInit(): void {
this.claims$ = this.userService.getClaims();
this.org$ = this.orgService.activeOrg();
}
}

View File

@@ -0,0 +1,28 @@
@if (claims$ | async; as claims) {
<button mat-icon-button [matMenuTriggerFor]="menu">
@if (claims.picture) {
<img class="profile-picture" [src]="claims.picture" alt="Profile"/>
} @else {
<mat-icon>account_circle</mat-icon>
}
</button>
<mat-menu class="accounts-menu" #menu="matMenu">
<mat-card class="accounts-card">
<mat-card-header>
<div mat-card-avatar class="accounts-header-image" [ngStyle]="{'background-image': claims.picture ? 'url(' + claims.picture +')' : 'url(/ui/assets/img/account_circle.svg)'}">
</div>
<mat-card-title>{{ claims.name }}</mat-card-title>
<mat-card-subtitle>{{ claims.email }}</mat-card-subtitle>
</mat-card-header>
<mat-card-actions>
<a mat-menu-item href="{{claims.iss}}/ui/console/users/me?id=general">
Profile
</a>
<a mat-menu-item href="{{claims.iss}}/oidc/v1/end_session">
Logout
</a>
</mat-card-actions>
</mat-card>
</mat-menu>
}

View File

@@ -0,0 +1,16 @@
.profile-picture {
border-radius: 50%;
}
.accounts-card {
max-width: 400px;
}
.accounts-header-image {
background-image: url('/ui/assets/img/account_circle.svg');
background-size: cover;
}
::ng-deep .mat-mdc-menu-content {
padding: 0px !important;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProfileButtonComponent } from './profile-button.component';
describe('ProfileButtonComponent', () => {
let component: ProfileButtonComponent;
let fixture: ComponentFixture<ProfileButtonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProfileButtonComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ProfileButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,27 @@
import { Component, Input } from '@angular/core';
import { Claims } from '../gen/holos/v1alpha1/user_pb';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { Observable } from 'rxjs';
import { AsyncPipe, NgIf, NgStyle } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatCardModule } from '@angular/material/card';
@Component({
selector: 'app-profile-button',
standalone: true,
imports: [
MatButtonModule,
MatMenuModule,
MatIconModule,
MatCardModule,
AsyncPipe,
NgIf,
NgStyle,
],
templateUrl: './profile-button.component.html',
styleUrl: './profile-button.component.scss'
})
export class ProfileButtonComponent {
@Input({ required: true }) claims$!: Observable<Claims | null>;
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { OrganizationService } from './organization.service';
describe('OrganizationService', () => {
let service: OrganizationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(OrganizationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,67 @@
import { Inject, Injectable } from '@angular/core';
import { OrganizationService as ConnectOrganizationService } from '../gen/holos/v1alpha1/organization_connect';
import { ObservableClient } from '../../connect/observable-client';
import { Observable, switchMap, of, shareReplay, catchError, BehaviorSubject } from 'rxjs';
import { GetCallerOrganizationsResponse, Organization } from '../gen/holos/v1alpha1/organization_pb';
import { UserService } from './user.service';
import { Code, ConnectError } from '@connectrpc/connect';
@Injectable({
providedIn: 'root'
})
export class OrganizationService {
private callerOrganizationsTrigger$ = new BehaviorSubject<void>(undefined);
private callerOrganizations$: Observable<GetCallerOrganizationsResponse>;
private fetchCallerOrganizations(): Observable<GetCallerOrganizationsResponse> {
return this.client.getCallerOrganizations({ request: {} }).pipe(
switchMap(resp => {
if (resp && resp.organizations.length > 0) {
return of(resp)
}
return this.client.createCallerOrganization({ request: {} })
}),
catchError(err => {
if (err instanceof ConnectError) {
if (err.code == Code.NotFound) {
return this.userService.createUser().pipe(
switchMap(user => this.client.createCallerOrganization({ request: {} }))
)
}
}
console.error('Error fetching data:', err);
throw err;
}),
)
}
getOrganizations(): Observable<Organization[]> {
return this.callerOrganizations$.pipe(
switchMap(resp => of(resp.organizations))
)
}
activeOrg(): Observable<Organization | undefined> {
return this.callerOrganizations$.pipe(
switchMap(resp => of(resp.organizations.at(-1)))
)
}
refreshOrganizations(): void {
this.callerOrganizationsTrigger$.next()
}
constructor(
@Inject(ConnectOrganizationService) private client: ObservableClient<typeof ConnectOrganizationService>,
private userService: UserService,
) {
this.callerOrganizations$ = this.callerOrganizationsTrigger$.pipe(
switchMap(() => this.fetchCallerOrganizations()),
shareReplay(1),
catchError(err => {
console.error('Error fetching data:', err);
throw err;
})
);
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,42 @@
import { Inject, Injectable } from '@angular/core';
import { Observable, switchMap, of, shareReplay } from 'rxjs';
import { ObservableClient } from '../../connect/observable-client';
import { Claims, User } from '../gen/holos/v1alpha1/user_pb';
import { UserService as ConnectUserService } from '../gen/holos/v1alpha1/user_connect';
@Injectable({
providedIn: 'root'
})
export class UserService {
getClaims(): Observable<Claims | null> {
return this.client.getCallerClaims({ request: {} }).pipe(
switchMap(getCallerClaimsResponse => {
if (getCallerClaimsResponse && getCallerClaimsResponse.claims) {
return of(getCallerClaimsResponse.claims)
} else {
return of(null)
}
}),
// Consolidate to one api call for all subscribers
shareReplay(1)
)
}
createUser(): Observable<User | null> {
return this.client.createCallerUser({ request: {} }).pipe(
switchMap(resp => {
if (resp && resp.user) {
return of(resp.user)
} else {
return of(null)
}
}),
shareReplay(1)
)
}
constructor(
@Inject(ConnectUserService) private client: ObservableClient<typeof ConnectUserService>,
) { }
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1,15 @@
import { Provider } from "@angular/core";
import { Transport } from "@connectrpc/connect";
import { ServiceType } from "@bufbuild/protobuf";
import { createObservableClient } from "./observable-client";
import { TRANSPORT } from "./transport.token";
export function provideClient<T extends ServiceType>(service: T): Provider {
return {
provide: service,
useFactory: (transport: Transport) => {
return createObservableClient(service, transport);
},
deps: [TRANSPORT],
};
}

View File

@@ -0,0 +1,30 @@
import { ModuleWithProviders, NgModule } from '@angular/core'
import { Interceptor } from '@connectrpc/connect'
import { createConnectTransport } from '@connectrpc/connect-web'
import { INTERCEPTORS } from './interceptor.token'
import { TRANSPORT } from './transport.token'
@NgModule()
export class ConnectModule {
public static forRoot(
connectOptions: Omit<
Parameters<typeof createConnectTransport>[0],
'interceptors'
>
): ModuleWithProviders<ConnectModule> {
return {
ngModule: ConnectModule,
providers: [
{
provide: TRANSPORT,
useFactory: (interceptors: Interceptor[]) =>
createConnectTransport({
...connectOptions,
interceptors: interceptors,
}),
deps: [INTERCEPTORS],
},
],
}
}
}

View File

@@ -0,0 +1,30 @@
import { ModuleWithProviders, NgModule } from '@angular/core'
import { Interceptor } from '@connectrpc/connect'
import { createGrpcWebTransport } from '@connectrpc/connect-web'
import { INTERCEPTORS } from './interceptor.token'
import { TRANSPORT } from './transport.token'
@NgModule()
export class GrpcWebModule {
public static forRoot(
grpcWebOptions: Omit<
Parameters<typeof createGrpcWebTransport>[0],
'interceptors'
>
): ModuleWithProviders<GrpcWebModule> {
return {
ngModule: GrpcWebModule,
providers: [
{
provide: TRANSPORT,
useFactory: (interceptors: Interceptor[]) =>
createGrpcWebTransport({
...grpcWebOptions,
interceptors: interceptors,
}),
deps: [INTERCEPTORS],
},
],
}
}
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken } from '@angular/core'
import type { Interceptor } from '@connectrpc/connect'
export const INTERCEPTORS = new InjectionToken<Interceptor[]>(
'connect.interceptors',
{
factory: () => [],
}
)

View File

@@ -0,0 +1,120 @@
import { makeAnyClient, CallOptions, Transport } from '@connectrpc/connect'
import { createAsyncIterable } from '@connectrpc/connect/protocol'
import {
ServiceType,
PartialMessage,
MethodInfoServerStreaming,
MethodInfo,
MethodInfoUnary,
MethodKind,
Message,
} from '@bufbuild/protobuf'
import { Observable } from 'rxjs'
export type ObservableClient<T extends ServiceType> = {
[P in keyof T['methods']]: T['methods'][P] extends MethodInfoUnary<
infer I,
infer O
>
? UnaryFn<I, O>
: T['methods'][P] extends MethodInfoServerStreaming<infer I, infer O>
? ServerStreamingFn<I, O>
: never
}
export function createObservableClient<T extends ServiceType>(
service: T,
transport: Transport
) {
return makeAnyClient(service, (method) => {
switch (method.kind) {
case MethodKind.Unary:
return createUnaryFn(transport, service, method)
case MethodKind.ServerStreaming:
return createServerStreamingFn(transport, service, method)
default:
return null
}
}) as ObservableClient<T>
}
type UnaryFn<I extends Message<I>, O extends Message<O>> = (
request: PartialMessage<I>,
options?: CallOptions
) => Observable<O>
function createUnaryFn<I extends Message<I>, O extends Message<O>>(
transport: Transport,
service: ServiceType,
method: MethodInfo<I, O>
): UnaryFn<I, O> {
return function (requestMessage, options) {
return new Observable<O>((subscriber) => {
transport
.unary(
service,
method,
options?.signal,
options?.timeoutMs,
options?.headers,
requestMessage
)
.then(
(response) => {
options?.onHeader?.(response.header)
subscriber.next(response.message)
options?.onTrailer?.(response.trailer)
},
(err) => {
subscriber.error(err)
}
)
.finally(() => {
subscriber.complete()
})
})
}
}
type ServerStreamingFn<I extends Message<I>, O extends Message<O>> = (
request: PartialMessage<I>,
options?: CallOptions
) => Observable<O>
export function createServerStreamingFn<
I extends Message<I>,
O extends Message<O>
>(
transport: Transport,
service: ServiceType,
method: MethodInfo<I, O>
): ServerStreamingFn<I, O> {
return function (input, options) {
return new Observable<O>((subscriber) => {
transport
.stream<I, O>(
service,
method,
options?.signal,
options?.timeoutMs,
options?.headers,
createAsyncIterable([input])
)
.then(
async (streamResponse) => {
options?.onHeader?.(streamResponse.header)
for await (const response of streamResponse.message) {
subscriber.next(response)
}
options?.onTrailer?.(streamResponse.trailer)
},
(err) => {
subscriber.error(err)
}
)
.finally(() => {
subscriber.complete()
})
})
}
}

View File

@@ -0,0 +1,4 @@
import { InjectionToken } from "@angular/core";
import type { Transport } from "@connectrpc/connect";
export const TRANSPORT = new InjectionToken<Transport>("connect.transport");

View File

@@ -3,7 +3,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
"types": [],
},
"files": [
"src/main.ts"

View File

@@ -8,12 +8,10 @@ import (
"unicode"
"connectrpc.com/connect"
"github.com/gofrs/uuid"
"github.com/holos-run/holos/internal/ent"
"github.com/holos-run/holos/internal/ent/user"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/server/middleware/authn"
"github.com/holos-run/holos/internal/server/middleware/logger"
holos "github.com/holos-run/holos/service/gen/holos/v1alpha1"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -67,7 +65,7 @@ func (h *OrganizationHandler) GetCallerOrganizations(
func (h *OrganizationHandler) CreateCallerOrganization(
ctx context.Context,
req *connect.Request[holos.CreateCallerOrganizationRequest],
) (*connect.Response[holos.CreateCallerOrganizationResponse], error) {
) (*connect.Response[holos.GetCallerOrganizationsResponse], error) {
authnID, err := authn.FromContext(ctx)
if err != nil {
return nil, connect.NewError(connect.CodePermissionDenied, errors.Wrap(err))
@@ -86,7 +84,7 @@ func (h *OrganizationHandler) CreateCallerOrganization(
err = WithTx(ctx, h.db, func(tx *ent.Tx) (err error) {
org, err = h.db.Organization.Create().
SetName(cleanAndAppendRandom(authnID.Name())).
SetDisplayName(authnID.Name() + "'s Org").
SetDisplayName(authnID.GivenName() + "'s Org").
SetCreatorID(dbUser.ID).
Save(ctx)
if err != nil {
@@ -111,7 +109,7 @@ func (h *OrganizationHandler) CreateCallerOrganization(
rpcOrgs = append(rpcOrgs, OrganizationToRPC(dbOrg))
}
res := connect.NewResponse(&holos.CreateCallerOrganizationResponse{
res := connect.NewResponse(&holos.GetCallerOrganizationsResponse{
User: UserToRPC(dbUser),
Organizations: rpcOrgs,
})
@@ -126,7 +124,7 @@ func cleanAndAppendRandom(s string) string {
return -1
}
cleaned := strings.Map(mapping, s)
randNum := rand.Intn(1_000_000)
randNum := rand.Intn(900_000) + 100_000
return fmt.Sprintf("%s-%06d", cleaned, randNum)
}
@@ -143,24 +141,3 @@ func OrganizationToRPC(org *ent.Organization) *holos.Organization {
}
return &rpcEntity
}
func createOrganization(ctx context.Context, client *ent.Client, name string, displayName string, creatorID uuid.UUID) (*ent.Organization, error) {
log := logger.FromContext(ctx)
// Create the user, error if it already exists
entity, err := client.Organization.
Create().
SetName(name).
SetDisplayName(displayName).
SetCreatorID(creatorID).
Save(ctx)
if err != nil {
err = connect.NewError(connect.CodeFailedPrecondition, errors.Wrap(err))
log.ErrorContext(ctx, "could not create user", "err", err)
return entity, err
}
log = log.With("organization", entity)
log.InfoContext(ctx, "created")
return entity, nil
}

View File

@@ -40,6 +40,9 @@ func (h *UserHandler) GetCallerClaims(
EmailVerified: authnID.Verified(),
Name: authnID.Name(),
Groups: authnID.Groups(),
GivenName: authnID.GivenName(),
FamilyName: authnID.FamilyName(),
Picture: authnID.Picture(),
},
})
return res, nil
@@ -135,20 +138,3 @@ func createUser(ctx context.Context, client *ent.Client, name string, claims aut
return user, nil
}
func getAuthenticatedUser(ctx context.Context, client *ent.Client) (*ent.User, error) {
authnIdentity, err := authn.FromContext(ctx)
if err != nil {
return nil, connect.NewError(connect.CodePermissionDenied, errors.Wrap(err))
}
log := logger.FromContext(ctx).With("iss", authnIdentity.Issuer(), "sub", authnIdentity.Subject(), "email", authnIdentity.Email())
user, err := client.User.Query().Where(
user.Iss(authnIdentity.Issuer()),
user.Sub(authnIdentity.Subject()),
).Only(ctx)
if err != nil {
log.DebugContext(ctx, "could not get user", "err", err)
return nil, errors.Wrap(err)
}
return user, nil
}

View File

@@ -49,6 +49,12 @@ type Identity interface {
Name() string
// Groups is the groups claim.
Groups() []string
// GivenName is the given name of the user.
GivenName() string
// FamilyName is the family name of the user.
FamilyName() string
// Picture is an optional avatar image url for the user.
Picture() string
}
// key is an unexported type for keys defined in this package to prevent
@@ -104,12 +110,15 @@ func NewVerifier(ctx context.Context, log *slog.Logger, issuer string) (*oidc.ID
}
type claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Email string `json:"email"`
Verified bool `json:"email_verified"`
Name string `json:"name"`
Groups []string `json:"groups"`
Issuer string `json:"iss"`
Subject string `json:"sub"`
Email string `json:"email"`
Verified bool `json:"email_verified"`
Name string `json:"name"`
Groups []string `json:"groups"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Picture string `json:"picture"`
}
type user struct {
@@ -136,6 +145,18 @@ func (u user) Groups() []string {
return u.claims.Groups
}
func (u user) GivenName() string {
return u.claims.GivenName
}
func (u user) FamilyName() string {
return u.claims.FamilyName
}
func (u user) Picture() string {
return u.claims.Picture
}
func (u user) Verified() bool {
return u.claims.Verified
}

View File

@@ -28,12 +28,7 @@ message GetCallerOrganizationsResponse {
message CreateCallerOrganizationRequest {}
message CreateCallerOrganizationResponse {
User user = 1;
repeated Organization organizations = 2;
}
service OrganizationService {
rpc GetCallerOrganizations(GetCallerOrganizationsRequest) returns (GetCallerOrganizationsResponse) {}
rpc CreateCallerOrganization(CreateCallerOrganizationRequest) returns (CreateCallerOrganizationResponse) {}
rpc CreateCallerOrganization(CreateCallerOrganizationRequest) returns (GetCallerOrganizationsResponse) {}
}

View File

@@ -36,6 +36,9 @@ message Claims {
bool email_verified = 4;
string name = 5 [(buf.validate.field).string.max_len = 100];
repeated string groups = 6;
string given_name = 7;
string family_name = 8;
string picture = 9;
}
// UserClaims represents id token claims

View File

@@ -1 +1 @@
69
70