Skip to content

Commit

Permalink
Project creation and selector
Browse files Browse the repository at this point in the history
  • Loading branch information
clarkmcc committed Nov 17, 2023
1 parent f046e02 commit b2c90ab
Show file tree
Hide file tree
Showing 25 changed files with 718 additions and 60 deletions.
27 changes: 27 additions & 0 deletions app/backend/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package appbackend
import (
"context"
"github.com/clarkmcc/cloudcore/cmd/cloudcore-server/database"
"github.com/graphql-go/graphql"
"github.com/spf13/cast"
"go.uber.org/zap"
)

Expand All @@ -22,3 +24,28 @@ func logger(ctx context.Context) *zap.Logger {
func db(ctx context.Context) database.Database {
return ctx.Value(contextKeyDatabase).(database.Database)
}

type resolveContext struct {
context.Context
db database.Database
params graphql.ResolveParams
logger *zap.Logger
}

func (r *resolveContext) GetStringArg(name string) string {
return cast.ToString(r.params.Args[name])
}

type resolverFunc[T any] func(rctx resolveContext) (T, error)

func wrapper[T any](fn resolverFunc[T]) func(params graphql.ResolveParams) (any, error) {
return func(params graphql.ResolveParams) (any, error) {
ctx := params.Context
return fn(resolveContext{
Context: ctx,
db: db(ctx),
logger: logger(ctx),
params: params,
})
}
}
12 changes: 3 additions & 9 deletions app/backend/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,15 @@ func Authentication(config *config.Config, logger *zap.Logger) gin.HandlerFunc {
c.Next()
return
}
token, err := parseToken(c.Request)
if err != nil {
_ = c.AbortWithError(http.StatusUnauthorized, errors.New("invalid auth header"))
return
}
rawClaims, err := v.ValidateToken(c, token)
claims, err := getRawClaims(c, v, logger, c.Request)
if err != nil {
_ = c.AbortWithError(http.StatusUnauthorized, err)
return
}

// Append the validated claims to the context if we have them
ctx := c.Request.Context()
if claims, ok := rawClaims.(*validator.ValidatedClaims); ok {
c.Request = c.Request.WithContext(withClaimsContext(ctx, claims))
if claims != nil {
c.Request = c.Request.WithContext(withClaimsContext(c.Request.Context(), claims))
}
c.Next()
}
Expand Down
36 changes: 36 additions & 0 deletions app/backend/middleware/auth_validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//go:build !dev

package middleware

import (
"context"
"fmt"
"github.com/auth0/go-jwt-middleware/v2/validator"
"go.uber.org/zap"
"net/http"
)

// getRawClaims extracts the raw claims from the request, however, it is meant
// only to be used in dev mode so that unauthenticated requests can still succeed
// such as requests to load the graphql schema.
//
// Any validation or parser errors are logged rather than returned.
func getRawClaims(
ctx context.Context,
v *validator.Validator,
_ *zap.Logger,
r *http.Request,
) (*validator.ValidatedClaims, error) {
token, err := parseToken(r)
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}
raw, err := v.ValidateToken(ctx, token)
if err != nil {
return nil, fmt.Errorf("failed to validate token: %w", err)
}
if claims, ok := raw.(*validator.ValidatedClaims); ok {
return claims, nil
}
return nil, fmt.Errorf("expected %T, got %T", &validator.ValidatedClaims{}, raw)
}
34 changes: 34 additions & 0 deletions app/backend/middleware/auth_validator_dev.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//go:build dev

package middleware

import (
"context"
"github.com/auth0/go-jwt-middleware/v2/validator"
"go.uber.org/zap"
"net/http"
)

// getRawClaims extracts the raw claims from the request, however, it is meant
// only to be used in dev mode so that unauthenticated requests can still succeed
// such as requests to load the graphql schema.
//
// Any validation or parser errors are logged rather than returned.
func getRawClaims(
ctx context.Context,
v *validator.Validator,
logger *zap.Logger,
r *http.Request,
) (*validator.ValidatedClaims, error) {
token, err := parseToken(r)
if err != nil {
logger.Error("auth failed in dev mode: failed to parse token", zap.Error(err))
return nil, nil
}
claims, err := v.ValidateToken(ctx, token)
if err != nil {
logger.Error("auth failed in dev mode: failed to validate token", zap.Error(err))
return nil, nil
}
return claims.(*validator.ValidatedClaims), nil
}
46 changes: 46 additions & 0 deletions app/backend/projects.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package appbackend

import (
"fmt"
"github.com/clarkmcc/cloudcore/app/backend/middleware"
"github.com/graphql-go/graphql"
)

var projectCreate = &graphql.Field{
Type: graphql.NewObject(graphql.ObjectConfig{
Name: "ProjectCreate",
Fields: graphql.Fields{
"project": &graphql.Field{
Type: projectType,
},
"allProjects": &graphql.Field{
Type: graphql.NewList(projectType),
},
},
}),
Args: graphql.FieldConfigArgument{
"name": &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
"description": &graphql.ArgumentConfig{
Type: graphql.String,
},
},
Resolve: wrapper(func(rctx resolveContext) (map[string]any, error) {
sub := middleware.SubjectFromContext(rctx)
project, err := rctx.db.CreateProject(rctx, sub,
rctx.GetStringArg("name"),
rctx.GetStringArg("description"))
if err != nil {
return nil, fmt.Errorf("creating new project: %w", err)
}
projects, err := rctx.db.GetUserProjects(rctx, sub)
if err != nil {
return nil, fmt.Errorf("getting user projects: %w", err)
}
return map[string]any{
"project": project,
"allProjects": projects,
}, nil
}),
}
3 changes: 2 additions & 1 deletion app/backend/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ var schemaConfig = graphql.SchemaConfig{
Mutation: graphql.NewObject(graphql.ObjectConfig{
Name: "Mutation",
Fields: graphql.Fields{
"ensureUser": ensureUser,
"ensureUser": ensureUser,
"projectCreate": projectCreate,
},
}),
}
Expand Down
9 changes: 4 additions & 5 deletions app/backend/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ package appbackend

import (
"github.com/clarkmcc/cloudcore/app/backend/middleware"
"github.com/clarkmcc/cloudcore/cmd/cloudcore-server/database/types"
"github.com/graphql-go/graphql"
)

var ensureUser = &graphql.Field{
Type: graphql.NewList(projectType),
Args: graphql.FieldConfigArgument{},
Resolve: func(p graphql.ResolveParams) (any, error) {
db := db(p.Context)
subject := middleware.SubjectFromContext(p.Context)
return db.UpsertUser(p.Context, subject)
},
Resolve: wrapper(func(rctx resolveContext) ([]types.Project, error) {
return rctx.db.UpsertUser(rctx, middleware.SubjectFromContext(rctx))
}),
}
3 changes: 3 additions & 0 deletions app/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
"@apollo/client": "^3.8.7",
"@auth0/auth0-react": "^2.2.3",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@tanstack/react-router": "^0.0.1-beta.212",
Expand All @@ -27,6 +29,7 @@
"prettier": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-router-dom": "^6.19.0",
"sort-by": "^1.2.0",
"tailwind-merge": "^2.0.0",
Expand Down
8 changes: 7 additions & 1 deletion app/frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ schema {
}

type Mutation {
ensureUser: Project
ensureUser: [Project]
projectCreate(description: String, name: String!): ProjectCreate
}

type Project {
Expand All @@ -17,6 +18,11 @@ type Project {
updated_at: DateTime
}

type ProjectCreate {
allProjects: [Project]
project: Project
}

type Query {
ping: String
}
Expand Down
5 changes: 4 additions & 1 deletion app/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { loadProject, ProjectHome } from "@/pages/project.tsx";
import { withAuthenticationRequired } from "@auth0/auth0-react";
import { useEnsureUser } from "@/hooks/user.ts";
import { AuthenticatedApolloProvider } from "@/queries/client.tsx";
import { ProjectProvider } from "@/context/project.tsx";

function Container() {
useEnsureUser();
Expand All @@ -29,7 +30,9 @@ function Container() {
const AuthenticatedContainer = withAuthenticationRequired(() => {
return (
<AuthenticatedApolloProvider>
<Container />
<ProjectProvider>
<Container />
</ProjectProvider>
</AuthenticatedApolloProvider>
);
});
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {
AvatarImage,
} from "@/components/ui/avatar.tsx";
import { useAuth0 } from "@auth0/auth0-react";
import { useProjectNavigate } from "@/hooks.ts";
import { ProjectSelector } from "@/components/project-selector.tsx";
import { useProjectNavigate } from "@/hooks/navigation.ts";

interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {}

Expand Down
75 changes: 75 additions & 0 deletions app/frontend/src/components/project-create-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { useForm } from "react-hook-form";
import { useCreateProject } from "@/hooks/projects.ts";

type ProjectCreateDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
};

interface Form {
name: string;
description?: string;
}

export function ProjectCreateDialog({
open,
setOpen,
}: ProjectCreateDialogProps) {
const { register, handleSubmit } = useForm<Form>();
const [create, status] = useCreateProject();

async function onSubmit(data: Form) {
await create(data.name, data.description);
setOpen(false);
}

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create a new project</DialogTitle>
<DialogDescription>
Projects are used to organize your hosts and groups. They act is
independent environments for you to manage your fleet.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">Name</Label>
<Input
required
type="name"
id="name"
placeholder="My project"
{...register("name", { required: true })}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="description">Description</Label>
<Input
type="description"
id="description"
placeholder="..."
{...register("description")}
/>
</div>
<Button type="submit" disabled={status.loading} className="w-full">
Create
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
Loading

0 comments on commit b2c90ab

Please sign in to comment.