Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb70300b37 | ||
|
|
5c6d7f4434 | ||
|
|
d1cd784866 | ||
|
|
db94b30b1c | ||
|
|
a0411a39f9 |
4
coderd/apidoc/docs.go
generated
4
coderd/apidoc/docs.go
generated
@@ -4845,8 +4845,8 @@ const docTemplate = `{
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
coderd/apidoc/swagger.json
generated
4
coderd/apidoc/swagger.json
generated
@@ -4273,8 +4273,8 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func init() {
|
||||
valid := NameValid(str)
|
||||
return valid == nil
|
||||
}
|
||||
for _, tag := range []string{"username", "organization_name", "template_name", "group_name", "workspace_name", "oauth2_app_name"} {
|
||||
for _, tag := range []string{"username", "organization_name", "template_name", "workspace_name", "oauth2_app_name"} {
|
||||
err := Validate.RegisterValidation(tag, nameValidator)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -96,6 +96,20 @@ func init() {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
groupNameValidator := func(fl validator.FieldLevel) bool {
|
||||
f := fl.Field().Interface()
|
||||
str, ok := f.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
valid := GroupNameValid(str)
|
||||
return valid == nil
|
||||
}
|
||||
err = Validate.RegisterValidation("group_name", groupNameValidator)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Is404Error returns true if the given error should return a 404 status code.
|
||||
|
||||
@@ -96,6 +96,23 @@ func UserRealNameValid(str string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GroupNameValid returns whether the input string is a valid group name.
|
||||
func GroupNameValid(str string) error {
|
||||
// 36 is to support using UUIDs as the group name.
|
||||
if len(str) > 36 {
|
||||
return xerrors.New("must be <= 36 characters")
|
||||
}
|
||||
// Avoid conflicts with routes like /groups/new and /groups/create.
|
||||
if str == "new" || str == "create" {
|
||||
return xerrors.Errorf("cannot use %q as a name", str)
|
||||
}
|
||||
matched := UsernameValidRegex.MatchString(str)
|
||||
if !matched {
|
||||
return xerrors.New("must be alphanumeric with hyphens")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeUserRealName normalizes a user name such that it will pass
|
||||
// validation by UserRealNameValid. This is done to avoid blocking
|
||||
// little Bobby Whitespace from using Coder.
|
||||
|
||||
@@ -513,7 +513,7 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Users
|
||||
// @Param user path string true "User ID, name, or me"
|
||||
// @Success 204
|
||||
// @Success 200
|
||||
// @Router /users/{user} [delete]
|
||||
func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
@@ -588,7 +588,9 @@ func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
||||
Message: "User has been deleted!",
|
||||
})
|
||||
}
|
||||
|
||||
// Returns the parameterized user requested. All validation
|
||||
|
||||
@@ -116,7 +116,19 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer mux.Close()
|
||||
|
||||
logger.Debug(ctx, "accepting agent RPC connection", slog.F("agent", workspaceAgent))
|
||||
logger.Debug(ctx, "accepting agent RPC connection",
|
||||
slog.F("agent_id", workspaceAgent.ID),
|
||||
slog.F("agent_created_at", workspaceAgent.CreatedAt),
|
||||
slog.F("agent_updated_at", workspaceAgent.UpdatedAt),
|
||||
slog.F("agent_name", workspaceAgent.Name),
|
||||
slog.F("agent_first_connected_at", workspaceAgent.FirstConnectedAt.Time),
|
||||
slog.F("agent_last_connected_at", workspaceAgent.LastConnectedAt.Time),
|
||||
slog.F("agent_disconnected_at", workspaceAgent.DisconnectedAt.Time),
|
||||
slog.F("agent_version", workspaceAgent.Version),
|
||||
slog.F("agent_last_connected_replica_id", workspaceAgent.LastConnectedReplicaID),
|
||||
slog.F("agent_connection_timeout_seconds", workspaceAgent.ConnectionTimeoutSeconds),
|
||||
slog.F("agent_api_version", workspaceAgent.APIVersion),
|
||||
slog.F("agent_resource_id", workspaceAgent.ResourceID))
|
||||
|
||||
closeCtx, closeCtxCancel := context.WithCancel(ctx)
|
||||
defer closeCtxCancel()
|
||||
|
||||
@@ -309,7 +309,9 @@ func (c *Client) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
// Check for a 200 or a 204 response. 2.14.0 accidentally included a 204 response,
|
||||
// which was a breaking change, and reverted in 2.14.1.
|
||||
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
|
||||
6
docs/api/users.md
generated
6
docs/api/users.md
generated
@@ -426,9 +426,9 @@ curl -X DELETE http://coder-server:8080/api/v2/users/{user} \
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
| ------ | --------------------------------------------------------------- | ----------- | ------ |
|
||||
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
|
||||
| Status | Meaning | Description | Schema |
|
||||
| ------ | ------------------------------------------------------- | ----------- | ------ |
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
|
||||
@@ -174,6 +174,10 @@ type LicenseOptions struct {
|
||||
// ExpiresAt is the time at which the license will hard expire.
|
||||
// ExpiresAt should always be greater then GraceAt.
|
||||
ExpiresAt time.Time
|
||||
// NotBefore is the time at which the license becomes valid. If set to the
|
||||
// zero value, the `nbf` claim on the license is set to 1 minute in the
|
||||
// past.
|
||||
NotBefore time.Time
|
||||
Features license.Features
|
||||
}
|
||||
|
||||
@@ -195,6 +199,13 @@ func (opts *LicenseOptions) Valid(now time.Time) *LicenseOptions {
|
||||
return opts
|
||||
}
|
||||
|
||||
func (opts *LicenseOptions) FutureTerm(now time.Time) *LicenseOptions {
|
||||
opts.NotBefore = now.Add(time.Hour * 24)
|
||||
opts.ExpiresAt = now.Add(time.Hour * 24 * 60)
|
||||
opts.GraceAt = now.Add(time.Hour * 24 * 53)
|
||||
return opts
|
||||
}
|
||||
|
||||
func (opts *LicenseOptions) UserLimit(limit int64) *LicenseOptions {
|
||||
return opts.Feature(codersdk.FeatureUserLimit, limit)
|
||||
}
|
||||
@@ -233,13 +244,16 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
|
||||
if options.GraceAt.IsZero() {
|
||||
options.GraceAt = time.Now().Add(time.Hour)
|
||||
}
|
||||
if options.NotBefore.IsZero() {
|
||||
options.NotBefore = time.Now().Add(-time.Minute)
|
||||
}
|
||||
|
||||
c := &license.Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: uuid.NewString(),
|
||||
Issuer: "test@testing.test",
|
||||
ExpiresAt: jwt.NewNumericDate(options.ExpiresAt),
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||
NotBefore: jwt.NewNumericDate(options.NotBefore),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||
},
|
||||
LicenseExpires: jwt.NewNumericDate(options.GraceAt),
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestPatchGroup(t *testing.T) {
|
||||
const displayName = "foobar"
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
group, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
|
||||
Name: "hi",
|
||||
Name: "ff7dcee2-e7c4-4bc4-a9e4-84870770e4c5", // GUID should fit.
|
||||
AvatarURL: "https://example.com",
|
||||
QuotaAllowance: 10,
|
||||
DisplayName: "",
|
||||
@@ -165,14 +165,14 @@ func TestPatchGroup(t *testing.T) {
|
||||
require.Equal(t, 10, group.QuotaAllowance)
|
||||
|
||||
group, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
|
||||
Name: "bye",
|
||||
Name: "ddd502d2-2984-4724-b5bf-1109a4d7462d", // GUID should fit.
|
||||
AvatarURL: ptr.Ref("https://google.com"),
|
||||
QuotaAllowance: ptr.Ref(20),
|
||||
DisplayName: ptr.Ref(displayName),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, displayName, group.DisplayName)
|
||||
require.Equal(t, "bye", group.Name)
|
||||
require.Equal(t, "ddd502d2-2984-4724-b5bf-1109a4d7462d", group.Name)
|
||||
require.Equal(t, "https://google.com", group.AvatarURL)
|
||||
require.Equal(t, 20, group.QuotaAllowance)
|
||||
})
|
||||
|
||||
@@ -100,6 +100,13 @@ func LicensesEntitlements(
|
||||
// 'Entitlements' group as a whole.
|
||||
for _, license := range licenses {
|
||||
claims, err := ParseClaims(license.JWT, keys)
|
||||
var vErr *jwt.ValidationError
|
||||
if xerrors.As(err, &vErr) && vErr.Is(jwt.ErrTokenNotValidYet) {
|
||||
// The license isn't valid yet. We don't consider any entitlements contained in it, but
|
||||
// it's also not an error. Just skip it silently. This can happen if an administrator
|
||||
// uploads a license for a new term that hasn't started yet.
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
entitlements.Errors = append(entitlements.Errors,
|
||||
fmt.Sprintf("Invalid license (%s) parsing claims: %s", license.UUID.String(), err.Error()))
|
||||
@@ -287,6 +294,8 @@ var (
|
||||
ErrInvalidVersion = xerrors.New("license must be version 3")
|
||||
ErrMissingKeyID = xerrors.Errorf("JOSE header must contain %s", HeaderKeyID)
|
||||
ErrMissingLicenseExpires = xerrors.New("license missing license_expires")
|
||||
ErrMissingExp = xerrors.New("exp claim missing or not parsable")
|
||||
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
|
||||
)
|
||||
|
||||
type Features map[codersdk.FeatureName]int64
|
||||
@@ -336,7 +345,7 @@ func ParseRaw(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error
|
||||
return nil, xerrors.New("unable to parse Claims")
|
||||
}
|
||||
|
||||
// ParseClaims validates a database.License record, and if valid, returns the claims. If
|
||||
// ParseClaims validates a raw JWT, and if valid, returns the claims. If
|
||||
// unparsable or invalid, it returns an error
|
||||
func ParseClaims(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, error) {
|
||||
tok, err := jwt.ParseWithClaims(
|
||||
@@ -348,18 +357,53 @@ func ParseClaims(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims, ok := tok.Claims.(*Claims); ok && tok.Valid {
|
||||
return validateClaims(tok)
|
||||
}
|
||||
|
||||
func validateClaims(tok *jwt.Token) (*Claims, error) {
|
||||
if claims, ok := tok.Claims.(*Claims); ok {
|
||||
if claims.Version != uint64(CurrentVersion) {
|
||||
return nil, ErrInvalidVersion
|
||||
}
|
||||
if claims.LicenseExpires == nil {
|
||||
return nil, ErrMissingLicenseExpires
|
||||
}
|
||||
if claims.ExpiresAt == nil {
|
||||
return nil, ErrMissingExp
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
return nil, xerrors.New("unable to parse Claims")
|
||||
}
|
||||
|
||||
// ParseClaimsIgnoreNbf validates a raw JWT, but ignores `nbf` claim. If otherwise valid, it returns
|
||||
// the claims. If unparsable or invalid, it returns an error. Ignoring the `nbf` (not before) is
|
||||
// useful to determine if a JWT _will_ become valid at any point now or in the future.
|
||||
func ParseClaimsIgnoreNbf(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, error) {
|
||||
tok, err := jwt.ParseWithClaims(
|
||||
rawJWT,
|
||||
&Claims{},
|
||||
keyFunc(keys),
|
||||
jwt.WithValidMethods(ValidMethods),
|
||||
)
|
||||
var vErr *jwt.ValidationError
|
||||
if xerrors.As(err, &vErr) {
|
||||
// zero out the NotValidYet error to check if there were other problems
|
||||
vErr.Errors = vErr.Errors & (^jwt.ValidationErrorNotValidYet)
|
||||
if vErr.Errors != 0 {
|
||||
// There are other errors besides not being valid yet. We _could_ go
|
||||
// through all the jwt.ValidationError bits and try to work out the
|
||||
// correct error, but if we get here something very strange is
|
||||
// going on so let's just return a generic error that says to get in
|
||||
// touch with our support team.
|
||||
return nil, ErrMultipleIssues
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return validateClaims(tok)
|
||||
}
|
||||
|
||||
func keyFunc(keys map[string]ed25519.PublicKey) func(*jwt.Token) (interface{}, error) {
|
||||
return func(j *jwt.Token) (interface{}, error) {
|
||||
keyID, ok := j.Header[HeaderKeyID].(string)
|
||||
|
||||
@@ -824,6 +824,23 @@ func TestLicenseEntitlements(t *testing.T) {
|
||||
assert.True(t, entitlements.Features[codersdk.FeatureMultipleOrganizations].Enabled, "multi-org enabled for premium")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "CurrentAndFuture",
|
||||
Licenses: []*coderdenttest.LicenseOptions{
|
||||
enterpriseLicense().UserLimit(100),
|
||||
premiumLicense().UserLimit(200).FutureTerm(time.Now()),
|
||||
},
|
||||
Enablements: defaultEnablements,
|
||||
AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
|
||||
assertEnterpriseFeatures(t, entitlements)
|
||||
assertNoErrors(t, entitlements)
|
||||
assertNoWarnings(t, entitlements)
|
||||
userFeature := entitlements.Features[codersdk.FeatureUserLimit]
|
||||
assert.Equalf(t, int64(100), *userFeature.Limit, "user limit")
|
||||
assert.Equal(t, codersdk.EntitlementNotEntitled,
|
||||
entitlements.Features[codersdk.FeatureMultipleOrganizations].Entitlement)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
||||
@@ -86,25 +86,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rawClaims, err := license.ParseRaw(addLicense.License, api.LicenseKeys)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid license",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
exp, ok := rawClaims["exp"].(float64)
|
||||
if !ok {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid license",
|
||||
Detail: "exp claim missing or not parsable",
|
||||
})
|
||||
return
|
||||
}
|
||||
expTime := time.Unix(int64(exp), 0)
|
||||
|
||||
claims, err := license.ParseClaims(addLicense.License, api.LicenseKeys)
|
||||
claims, err := license.ParseClaimsIgnoreNbf(addLicense.License, api.LicenseKeys)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid license",
|
||||
@@ -134,7 +116,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
|
||||
dl, err := api.Database.InsertLicense(ctx, database.InsertLicenseParams{
|
||||
UploadedAt: dbtime.Now(),
|
||||
JWT: addLicense.License,
|
||||
Exp: expTime,
|
||||
Exp: claims.ExpiresAt.Time,
|
||||
UUID: id,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -160,7 +142,15 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
|
||||
// don't fail the HTTP request, since we did write it successfully to the database
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, rawClaims))
|
||||
c, err := decodeClaims(dl)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to decode database response",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, c))
|
||||
}
|
||||
|
||||
// postRefreshEntitlements forces an `updateEntitlements` call and publishes
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -82,6 +83,53 @@ func TestPostLicense(t *testing.T) {
|
||||
t.Error("expected to get error status 400")
|
||||
}
|
||||
})
|
||||
|
||||
// Test a license that isn't yet valid, but will be in the future. We should allow this so that
|
||||
// operators can upload a license ahead of time.
|
||||
t.Run("NotYet", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
|
||||
respLic := coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
AccountType: license.AccountTypeSalesforce,
|
||||
AccountID: "testing",
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAuditLog: 1,
|
||||
},
|
||||
NotBefore: time.Now().Add(time.Hour),
|
||||
GraceAt: time.Now().Add(2 * time.Hour),
|
||||
ExpiresAt: time.Now().Add(3 * time.Hour),
|
||||
})
|
||||
assert.GreaterOrEqual(t, respLic.ID, int32(0))
|
||||
// just a couple spot checks for sanity
|
||||
assert.Equal(t, "testing", respLic.Claims["account_id"])
|
||||
features, err := respLic.FeaturesClaims()
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, features[codersdk.FeatureAuditLog])
|
||||
})
|
||||
|
||||
// Test we still reject a license that isn't valid yet, but has other issues (e.g. expired
|
||||
// before it starts).
|
||||
t.Run("NotEver", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
|
||||
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
|
||||
AccountType: license.AccountTypeSalesforce,
|
||||
AccountID: "testing",
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAuditLog: 1,
|
||||
},
|
||||
NotBefore: time.Now().Add(time.Hour),
|
||||
GraceAt: time.Now().Add(2 * time.Hour),
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
})
|
||||
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
|
||||
License: lic,
|
||||
})
|
||||
errResp := &codersdk.Error{}
|
||||
require.ErrorAs(t, err, &errResp)
|
||||
require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
|
||||
require.Contains(t, errResp.Detail, license.ErrMultipleIssues.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLicense(t *testing.T) {
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
name = "coder-${osArch}";
|
||||
# Updated with ./scripts/update-flake.sh`.
|
||||
# This should be updated whenever go.mod changes!
|
||||
vendorHash = "sha256-SkIcowUjVHuwCAJ9b1SwWD7V91UN7rHKMuLUSRquUl4=";
|
||||
vendorHash = "sha256-VRn69VifEhmriaH461JRSbPs0eWtvZx7g2X3KtnhBN0=";
|
||||
proxyVendor = true;
|
||||
src = ./.;
|
||||
nativeBuildInputs = with pkgs; [ getopt openssl zstd ];
|
||||
|
||||
9
go.mod
9
go.mod
@@ -207,11 +207,15 @@ require (
|
||||
require (
|
||||
cloud.google.com/go/auth v0.7.3 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/DataDog/go-libddwaf/v2 v2.4.2 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
||||
github.com/mitchellh/hashstructure v1.1.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/pion/transport/v2 v2.0.0 // indirect
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
|
||||
@@ -270,8 +274,8 @@ require (
|
||||
github.com/containerd/continuity v0.4.2 // indirect
|
||||
github.com/coreos/go-iptables v0.6.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/docker/cli v23.0.5+incompatible // indirect
|
||||
github.com/docker/docker v24.0.9+incompatible // indirect
|
||||
github.com/docker/cli v27.1.1+incompatible // indirect
|
||||
github.com/docker/docker v27.1.1+incompatible // indirect
|
||||
github.com/docker/go-connections v0.4.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -324,7 +328,6 @@ require (
|
||||
github.com/hashicorp/terraform-plugin-sdk/v2 v2.20.0 // indirect
|
||||
github.com/hdevalence/ed25519consensus v0.1.0 // indirect
|
||||
github.com/illarion/gonotify v1.0.1 // indirect
|
||||
github.com/imdario/mergo v0.3.15 // indirect
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
|
||||
20
go.sum
20
go.sum
@@ -11,6 +11,8 @@ cloud.google.com/go/logging v1.11.0 h1:v3ktVzXMV7CwHq1MBF65wcqLMA7i+z3YxbUsoK7mO
|
||||
cloud.google.com/go/logging v1.11.0/go.mod h1:5LDiJC/RxTt+fHc1LAt20R9TKiUTReDg6RuuFOZ67+A=
|
||||
cloud.google.com/go/longrunning v0.5.11 h1:Havn1kGjz3whCfoD8dxMLP73Ph5w+ODyZB9RUsDxtGk=
|
||||
cloud.google.com/go/longrunning v0.5.11/go.mod h1:rDn7//lmlfWV1Dx6IB4RatCPenTwwmqXuiP0/RgoEO4=
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
||||
@@ -256,14 +258,14 @@ github.com/dhui/dktest v0.4.0 h1:z05UmuXZHO/bgj/ds2bGMBu8FI4WA+Ag/m3ghL+om7M=
|
||||
github.com/dhui/dktest v0.4.0/go.mod h1:v/Dbz1LgCBOi2Uki2nUqLBGa83hWBGFMu5MrgMDCc78=
|
||||
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
|
||||
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/cli v23.0.5+incompatible h1:ufWmAOuD3Vmr7JP2G5K3cyuNC4YZWiAsuDEvFVVDafE=
|
||||
github.com/docker/cli v23.0.5+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
|
||||
github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
|
||||
github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE=
|
||||
github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
|
||||
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
|
||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
@@ -393,6 +395,8 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
|
||||
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc=
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA=
|
||||
github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
@@ -576,8 +580,6 @@ github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJ
|
||||
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
|
||||
github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio=
|
||||
github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE=
|
||||
github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM=
|
||||
github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
|
||||
github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY=
|
||||
@@ -708,6 +710,8 @@ github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby v27.1.1+incompatible h1:WdCIKJ4WIxhrKti5c+Z7sj2SLADbsuB/reEBpQ4rtOQ=
|
||||
github.com/moby/moby v27.1.1+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
|
||||
@@ -723,7 +723,7 @@ func (r *Runner) runTemplateImportProvisionWithRichParameters(
|
||||
|
||||
r.logger.Info(context.Background(), "parse dry-run provision successful",
|
||||
slog.F("resource_count", len(c.Resources)),
|
||||
slog.F("resources", c.Resources),
|
||||
slog.F("resources", resourceNames(c.Resources)),
|
||||
)
|
||||
|
||||
return &templateImportProvision{
|
||||
@@ -853,7 +853,7 @@ func (r *Runner) buildWorkspace(ctx context.Context, stage string, req *sdkproto
|
||||
func (r *Runner) commitQuota(ctx context.Context, resources []*sdkproto.Resource) *proto.FailedJob {
|
||||
cost := sumDailyCost(resources)
|
||||
r.logger.Debug(ctx, "committing quota",
|
||||
slog.F("resources", resources),
|
||||
slog.F("resources", resourceNames(resources)),
|
||||
slog.F("cost", cost),
|
||||
)
|
||||
if cost == 0 {
|
||||
@@ -964,7 +964,7 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p
|
||||
|
||||
r.logger.Info(context.Background(), "plan request successful",
|
||||
slog.F("resource_count", len(planComplete.Resources)),
|
||||
slog.F("resources", planComplete.Resources),
|
||||
slog.F("resources", resourceNames(planComplete.Resources)),
|
||||
)
|
||||
r.flushQueuedLogs(ctx)
|
||||
if commitQuota {
|
||||
@@ -1015,7 +1015,7 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p
|
||||
|
||||
r.logger.Info(context.Background(), "apply successful",
|
||||
slog.F("resource_count", len(applyComplete.Resources)),
|
||||
slog.F("resources", applyComplete.Resources),
|
||||
slog.F("resources", resourceNames(applyComplete.Resources)),
|
||||
slog.F("state_len", len(applyComplete.State)),
|
||||
)
|
||||
r.flushQueuedLogs(ctx)
|
||||
@@ -1031,6 +1031,19 @@ func (r *Runner) runWorkspaceBuild(ctx context.Context) (*proto.CompletedJob, *p
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resourceNames(rs []*sdkproto.Resource) []string {
|
||||
var sb strings.Builder
|
||||
names := make([]string, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
_, _ = sb.WriteString(r.Type)
|
||||
_, _ = sb.WriteString(".")
|
||||
_, _ = sb.WriteString(r.Name)
|
||||
names = append(names, sb.String())
|
||||
sb.Reset()
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (r *Runner) failedWorkspaceBuildf(format string, args ...interface{}) *proto.FailedJob {
|
||||
failedJob := r.failedJobf(format, args...)
|
||||
failedJob.Type = &proto.FailedJob_WorkspaceBuild_{}
|
||||
|
||||
@@ -19,6 +19,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
||||
agpl="${CODER_BUILD_AGPL:-0}"
|
||||
output_path=""
|
||||
version=""
|
||||
sign_windows="${CODER_SIGN_WINDOWS:-0}"
|
||||
|
||||
args="$(getopt -o "" -l agpl,output:,version: -- "$@")"
|
||||
eval set -- "$args"
|
||||
@@ -51,6 +52,11 @@ if [[ "$output_path" == "" ]]; then
|
||||
error "--output is a required parameter"
|
||||
fi
|
||||
|
||||
if [[ "$sign_windows" == 1 ]]; then
|
||||
dependencies java
|
||||
requiredenvs JSIGN_PATH EV_KEYSTORE EV_KEY EV_CERTIFICATE_PATH EV_TSA_URL GCLOUD_ACCESS_TOKEN
|
||||
fi
|
||||
|
||||
if [[ "$#" != 1 ]]; then
|
||||
error "Exactly one argument must be provided to this script, $# were supplied"
|
||||
fi
|
||||
@@ -125,3 +131,7 @@ popd
|
||||
cp "$temp_dir/installer.exe" "$output_path"
|
||||
|
||||
rm -rf "$temp_dir"
|
||||
|
||||
if [[ "$sign_windows" == 1 ]]; then
|
||||
execrelative ./sign_windows.sh "$output_path" 1>&2
|
||||
fi
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("useAgentLogs", () => {
|
||||
expect(wsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return existing logs without network calls", async () => {
|
||||
it("should return existing logs without network calls if state is off", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
queryClient.setQueryData(
|
||||
agentLogsKey(MockWorkspace.id, MockWorkspaceAgent.id),
|
||||
@@ -39,7 +39,7 @@ describe("useAgentLogs", () => {
|
||||
const { result } = renderUseAgentLogs(queryClient, {
|
||||
workspaceId: MockWorkspace.id,
|
||||
agentId: MockWorkspaceAgent.id,
|
||||
agentLifeCycleState: "ready",
|
||||
agentLifeCycleState: "off",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current).toHaveLength(5);
|
||||
@@ -48,12 +48,12 @@ describe("useAgentLogs", () => {
|
||||
expect(wsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fetch logs when empty and should not connect to WebSocket when not starting", async () => {
|
||||
it("should fetch logs when empty", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const fetchSpy = jest
|
||||
.spyOn(API, "getWorkspaceAgentLogs")
|
||||
.mockResolvedValueOnce(generateLogs(5));
|
||||
const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs");
|
||||
jest.spyOn(APIModule, "watchWorkspaceAgentLogs");
|
||||
const { result } = renderUseAgentLogs(queryClient, {
|
||||
workspaceId: MockWorkspace.id,
|
||||
agentId: MockWorkspaceAgent.id,
|
||||
@@ -63,10 +63,9 @@ describe("useAgentLogs", () => {
|
||||
expect(result.current).toHaveLength(5);
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledWith(MockWorkspaceAgent.id);
|
||||
expect(wsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fetch logs and connect to websocket when agent is starting", async () => {
|
||||
it("should fetch logs and connect to websocket", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const logs = generateLogs(5);
|
||||
const fetchSpy = jest
|
||||
|
||||
@@ -17,16 +17,13 @@ export type UseAgentLogsOptions = Readonly<{
|
||||
|
||||
/**
|
||||
* Defines a custom hook that gives you all workspace agent logs for a given
|
||||
* workspace.
|
||||
*
|
||||
* Depending on the status of the workspace, all logs may or may not be
|
||||
* available.
|
||||
* workspace.Depending on the status of the workspace, all logs may or may not
|
||||
* be available.
|
||||
*/
|
||||
export function useAgentLogs(
|
||||
options: UseAgentLogsOptions,
|
||||
): readonly WorkspaceAgentLog[] | undefined {
|
||||
const { workspaceId, agentId, agentLifeCycleState, enabled = true } = options;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const queryOptions = agentLogs(workspaceId, agentId);
|
||||
const { data: logs, isFetched } = useQuery({ ...queryOptions, enabled });
|
||||
@@ -55,7 +52,17 @@ export function useAgentLogs(
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (agentLifeCycleState !== "starting" || !isFetched) {
|
||||
// Stream data only for new logs. Old logs should be loaded beforehand
|
||||
// using a regular fetch to avoid overloading the websocket with all
|
||||
// logs at once.
|
||||
if (!isFetched) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the agent is off, we don't need to stream logs. This is the only state
|
||||
// where the Coder API can't receive logs for the agent from third-party
|
||||
// apps like envbuilder.
|
||||
if (agentLifeCycleState === "off") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,15 @@ export const LoginPage: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
|
||||
let redirectError: Error | null = null;
|
||||
let redirectUrl: URL | null = null;
|
||||
try {
|
||||
redirectUrl = new URL(redirectTo);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
const isApiRouteRedirect = redirectTo.startsWith("/api/v2");
|
||||
|
||||
useEffect(() => {
|
||||
if (!buildInfoQuery.data || isSignedIn) {
|
||||
@@ -42,41 +51,24 @@ export const LoginPage: FC = () => {
|
||||
}, [isSignedIn, buildInfoQuery.data, user?.id]);
|
||||
|
||||
if (isSignedIn) {
|
||||
if (buildInfoQuery.data) {
|
||||
// This uses `navigator.sendBeacon`, so window.href
|
||||
// will not stop the request from being sent!
|
||||
sendDeploymentEvent(buildInfoQuery.data, {
|
||||
type: "deployment_login",
|
||||
user_id: user?.id,
|
||||
});
|
||||
// The reason we need `window.location.href` for api redirects is that
|
||||
// we need the page to reload and make a request to the backend. If we
|
||||
// use `<Navigate>`, react would handle the redirect itself and never
|
||||
// request the page from the backend.
|
||||
if (isApiRouteRedirect) {
|
||||
const sanitizedUrl = new URL(redirectTo, window.location.origin);
|
||||
window.location.href = sanitizedUrl.pathname + sanitizedUrl.search;
|
||||
// Setting the href should immediately request a new page. Show an
|
||||
// error state if it doesn't.
|
||||
redirectError = new Error("unable to redirect");
|
||||
} else {
|
||||
return (
|
||||
<Navigate
|
||||
to={redirectUrl ? redirectUrl.pathname : redirectTo}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// If the redirect is going to a workspace application, and we
|
||||
// are missing authentication, then we need to change the href location
|
||||
// to trigger a HTTP request. This allows the BE to generate the auth
|
||||
// cookie required. Similarly for the OAuth2 exchange as the authorization
|
||||
// page is served by the backend.
|
||||
// If no redirect is present, then ignore this branched logic.
|
||||
if (redirectTo !== "" && redirectTo !== "/") {
|
||||
try {
|
||||
// This catches any absolute redirects. Relative redirects
|
||||
// will fail the try/catch. Subdomain apps are absolute redirects.
|
||||
const redirectURL = new URL(redirectTo);
|
||||
if (redirectURL.host !== window.location.host) {
|
||||
window.location.href = redirectTo;
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
// Path based apps and OAuth2.
|
||||
if (redirectTo.includes("/apps/") || redirectTo.includes("/oauth2/")) {
|
||||
window.location.href = redirectTo;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return <Navigate to={redirectTo} replace />;
|
||||
}
|
||||
|
||||
if (isConfiguringTheFirstUser) {
|
||||
@@ -90,7 +82,7 @@ export const LoginPage: FC = () => {
|
||||
</Helmet>
|
||||
<LoginPageView
|
||||
authMethods={authMethodsQuery.data}
|
||||
error={signInError}
|
||||
error={signInError ?? redirectError}
|
||||
isLoading={isLoading || authMethodsQuery.isLoading}
|
||||
buildInfo={buildInfoQuery.data}
|
||||
isSigningIn={isSigningIn}
|
||||
@@ -98,6 +90,7 @@ export const LoginPage: FC = () => {
|
||||
await signIn(email, password);
|
||||
navigate("/");
|
||||
}}
|
||||
redirectTo={redirectTo}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated";
|
||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { getApplicationName, getLogoURL } from "utils/appearance";
|
||||
import { retrieveRedirect } from "utils/redirect";
|
||||
import { SignInForm } from "./SignInForm";
|
||||
import { TermsOfServiceLink } from "./TermsOfServiceLink";
|
||||
|
||||
@@ -17,6 +16,7 @@ export interface LoginPageViewProps {
|
||||
buildInfo?: BuildInfoResponse;
|
||||
isSigningIn: boolean;
|
||||
onSignIn: (credentials: { email: string; password: string }) => void;
|
||||
redirectTo: string;
|
||||
}
|
||||
|
||||
export const LoginPageView: FC<LoginPageViewProps> = ({
|
||||
@@ -26,9 +26,9 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
|
||||
buildInfo,
|
||||
isSigningIn,
|
||||
onSignIn,
|
||||
redirectTo,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const redirectTo = retrieveRedirect(location.search);
|
||||
// This allows messages to be displayed at the top of the sign in form.
|
||||
// Helpful for any redirects that want to inform the user of something.
|
||||
const message = new URLSearchParams(location.search).get("message");
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { screen, userEvent } from "@storybook/test";
|
||||
import { CreateTemplateButton } from "./CreateTemplateButton";
|
||||
|
||||
const meta: Meta<typeof CreateTemplateButton> = {
|
||||
title: "pages/TemplatesPage/CreateTemplateButton",
|
||||
component: CreateTemplateButton,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CreateTemplateButton>;
|
||||
|
||||
export const Close: Story = {};
|
||||
|
||||
export const Open: Story = {
|
||||
play: async ({ step }) => {
|
||||
const user = userEvent.setup();
|
||||
await step("click on trigger", async () => {
|
||||
await user.click(screen.getByRole("button"));
|
||||
});
|
||||
},
|
||||
};
|
||||
56
site/src/pages/TemplatesPage/CreateTemplateButton.tsx
Normal file
56
site/src/pages/TemplatesPage/CreateTemplateButton.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import AddIcon from "@mui/icons-material/AddOutlined";
|
||||
import Inventory2 from "@mui/icons-material/Inventory2";
|
||||
import NoteAddOutlined from "@mui/icons-material/NoteAddOutlined";
|
||||
import UploadOutlined from "@mui/icons-material/UploadOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
MoreMenu,
|
||||
MoreMenuContent,
|
||||
MoreMenuItem,
|
||||
MoreMenuTrigger,
|
||||
} from "components/MoreMenu/MoreMenu";
|
||||
|
||||
type CreateTemplateButtonProps = {
|
||||
onNavigate: (path: string) => void;
|
||||
};
|
||||
|
||||
export const CreateTemplateButton: FC<CreateTemplateButtonProps> = ({
|
||||
onNavigate,
|
||||
}) => {
|
||||
return (
|
||||
<MoreMenu>
|
||||
<MoreMenuTrigger>
|
||||
<Button startIcon={<AddIcon />} variant="contained">
|
||||
Create Template
|
||||
</Button>
|
||||
</MoreMenuTrigger>
|
||||
<MoreMenuContent>
|
||||
<MoreMenuItem
|
||||
onClick={() => {
|
||||
onNavigate("/templates/new?exampleId=scratch");
|
||||
}}
|
||||
>
|
||||
<NoteAddOutlined />
|
||||
From scratch
|
||||
</MoreMenuItem>
|
||||
<MoreMenuItem
|
||||
onClick={() => {
|
||||
onNavigate("/templates/new");
|
||||
}}
|
||||
>
|
||||
<UploadOutlined />
|
||||
Upload template
|
||||
</MoreMenuItem>
|
||||
<MoreMenuItem
|
||||
onClick={() => {
|
||||
onNavigate("/starter-templates");
|
||||
}}
|
||||
>
|
||||
<Inventory2 />
|
||||
Choose a starter template
|
||||
</MoreMenuItem>
|
||||
</MoreMenuContent>
|
||||
</MoreMenu>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ test("create template from scratch", async () => {
|
||||
element: <TemplatesPage />,
|
||||
},
|
||||
{
|
||||
path: "/starter-templates",
|
||||
path: "/templates/new",
|
||||
element: <div data-testid="new-template-page" />,
|
||||
},
|
||||
],
|
||||
@@ -34,6 +34,9 @@ test("create template from scratch", async () => {
|
||||
name: "Create Template",
|
||||
});
|
||||
await user.click(createTemplateButton);
|
||||
const fromScratchMenuItem = await screen.findByText("From scratch");
|
||||
await user.click(fromScratchMenuItem);
|
||||
await screen.findByTestId("new-template-page");
|
||||
expect(router.state.location.pathname).toBe("/starter-templates");
|
||||
expect(router.state.location.pathname).toBe("/templates/new");
|
||||
expect(router.state.location.search).toBe("?exampleId=scratch");
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
TableRowSkeleton,
|
||||
} from "components/TableLoader/TableLoader";
|
||||
import { useClickableTableRow } from "hooks/useClickableTableRow";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { linkToTemplate, useLinks } from "modules/navigation";
|
||||
import { createDayString } from "utils/createDayString";
|
||||
import { docs } from "utils/docs";
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
formatTemplateBuildTime,
|
||||
formatTemplateActiveDevelopers,
|
||||
} from "utils/templates";
|
||||
import { CreateTemplateButton } from "./CreateTemplateButton";
|
||||
import { EmptyTemplates } from "./EmptyTemplates";
|
||||
|
||||
export const Language = {
|
||||
@@ -167,38 +169,40 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
|
||||
examples,
|
||||
canCreateTemplates,
|
||||
}) => {
|
||||
const { experiments } = useDashboard();
|
||||
const isLoading = !templates;
|
||||
const isEmpty = templates && templates.length === 0;
|
||||
const navigate = useNavigate();
|
||||
const multiOrgExperimentEnabled = experiments.includes("multi-organization");
|
||||
|
||||
const createTemplateAction = () => {
|
||||
return multiOrgExperimentEnabled ? (
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
navigate("/starter-templates");
|
||||
}}
|
||||
>
|
||||
Create Template
|
||||
</Button>
|
||||
) : (
|
||||
<CreateTemplateButton onNavigate={navigate} />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
<PageHeader
|
||||
actions={
|
||||
canCreateTemplates && (
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
navigate("/starter-templates");
|
||||
}}
|
||||
>
|
||||
Create Template
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<PageHeader actions={canCreateTemplates && createTemplateAction()}>
|
||||
<PageHeaderTitle>
|
||||
<Stack spacing={1} direction="row" alignItems="center">
|
||||
Templates
|
||||
<TemplateHelpTooltip />
|
||||
</Stack>
|
||||
</PageHeaderTitle>
|
||||
{templates && templates.length > 0 && (
|
||||
<PageHeaderSubtitle>
|
||||
Select a template to create a workspace.
|
||||
</PageHeaderSubtitle>
|
||||
)}
|
||||
<PageHeaderSubtitle>
|
||||
Select a template to create a workspace.
|
||||
</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
{error ? (
|
||||
@@ -212,7 +216,7 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
|
||||
<TableCell width="15%">{Language.usedByLabel}</TableCell>
|
||||
<TableCell width="10%">{Language.buildTimeLabel}</TableCell>
|
||||
<TableCell width="15%">{Language.lastUpdatedLabel}</TableCell>
|
||||
<TableCell width="1%"></TableCell>
|
||||
<TableCell width="1%" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
||||
@@ -472,6 +472,10 @@ export const router = createBrowserRouter(
|
||||
|
||||
{/* Pages that don't have the dashboard layout */}
|
||||
<Route path="/:username/:workspace" element={<WorkspacePage />} />
|
||||
<Route
|
||||
path="/templates/:template/versions/:version/edit"
|
||||
element={<TemplateVersionEditorPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/templates/:organization/:template/versions/:version/edit"
|
||||
element={<TemplateVersionEditorPage />}
|
||||
|
||||
Reference in New Issue
Block a user