Skip to content

Commit f73b20f

Browse files
committed
[dashboard,papi] allow no expiration date PAT
1 parent e404a42 commit f73b20f

File tree

8 files changed

+83
-50
lines changed

8 files changed

+83
-50
lines changed

components/dashboard/src/user-settings/PersonalAccessTokens.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,23 @@ export enum TokenAction {
4747
Delete = "DELETE",
4848
}
4949

50+
export const TokenExpirationForever = "EXPIRATION_FOREVER";
5051
export const TokenExpirationDays = [
5152
{ value: "7", label: "7 Days" },
5253
{ value: "30", label: "30 Days" },
5354
{ value: "60", label: "60 Days" },
5455
{ value: "180", label: "180 Days" },
56+
// TODO: show warning for it?
57+
{ value: TokenExpirationForever, label: "No expiration" },
5558
];
5659

60+
export function getTokenExpirationDescription(date?: Date | null) {
61+
if (!date) {
62+
return "The token will never expire!";
63+
}
64+
return `The token will expire on ${dayjs(date).format("MMM D, YYYY")}`;
65+
}
66+
5767
export const AllPermissions: PermissionDetail[] = [
5868
{
5969
name: "Full Access",

components/dashboard/src/user-settings/PersonalAccessTokensCreateView.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,21 @@
55
*/
66

77
import { PersonalAccessToken } from "@gitpod/public-api/lib/gitpod/experimental/v1/tokens_pb";
8-
import dayjs from "dayjs";
98
import { useEffect, useState } from "react";
109
import { Redirect, useHistory, useParams } from "react-router";
1110
import Alert from "../components/Alert";
1211
import DateSelector from "../components/DateSelector";
1312
import { SpinnerOverlayLoader } from "../components/Loader";
1413
import { personalAccessTokensService } from "../service/public-api";
1514
import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu";
16-
import { AllPermissions, TokenAction, TokenExpirationDays, TokenInfo } from "./PersonalAccessTokens";
15+
import {
16+
AllPermissions,
17+
TokenAction,
18+
TokenExpirationDays,
19+
TokenExpirationForever,
20+
TokenInfo,
21+
getTokenExpirationDescription,
22+
} from "./PersonalAccessTokens";
1723
import { settingsPathPersonalAccessTokens } from "./settings.routes";
1824
import ShowTokenModal from "./ShowTokenModal";
1925
import { Timestamp } from "@bufbuild/protobuf";
@@ -27,7 +33,7 @@ import { TextInputField } from "../components/forms/TextInputField";
2733
interface EditPATData {
2834
name: string;
2935
expirationDays: string;
30-
expirationDate: Date;
36+
expirationDate: Date | null;
3137
scopes: Set<string>;
3238
}
3339

@@ -84,7 +90,9 @@ function PersonalAccessTokenCreateView() {
8490
}, []);
8591

8692
const update = (change: Partial<EditPATData>, addScopes?: string[], removeScopes?: string[]) => {
87-
if (change.expirationDays) {
93+
if (change.expirationDays === TokenExpirationForever) {
94+
change.expirationDate = null;
95+
} else {
8896
change.expirationDate = new Date(Date.now() + Number(change.expirationDays) * 24 * 60 * 60 * 1000);
8997
}
9098
const data = { ...token, ...change };
@@ -98,11 +106,11 @@ function PersonalAccessTokenCreateView() {
98106
setToken(data);
99107
};
100108

101-
const handleRegenerate = async (tokenId: string, expirationDate: Date) => {
109+
const handleRegenerate = async (tokenId: string, expirationDate: Date | null) => {
102110
try {
103111
const resp = await personalAccessTokensService.regeneratePersonalAccessToken({
104112
id: tokenId,
105-
expirationTime: Timestamp.fromDate(expirationDate),
113+
expirationTime: expirationDate ? Timestamp.fromDate(expirationDate) : undefined,
106114
});
107115
backToListView({ method: TokenAction.Regenerate, data: resp.token! });
108116
} catch (e) {
@@ -134,7 +142,7 @@ function PersonalAccessTokenCreateView() {
134142
: await personalAccessTokensService.createPersonalAccessToken({
135143
token: {
136144
name: token.name,
137-
expirationTime: Timestamp.fromDate(token.expirationDate),
145+
expirationTime: token.expirationDate ? Timestamp.fromDate(token.expirationDate) : undefined,
138146
scopes: Array.from(token.scopes),
139147
},
140148
});
@@ -218,9 +226,7 @@ function PersonalAccessTokenCreateView() {
218226
{!isEditing && (
219227
<DateSelector
220228
title="Expiration Date"
221-
description={`The token will expire on ${dayjs(token.expirationDate).format(
222-
"MMM D, YYYY",
223-
)}`}
229+
description={getTokenExpirationDescription(token.expirationDate)}
224230
options={TokenExpirationDays}
225231
value={TokenExpirationDays.find((i) => i.value === token.expirationDays)?.value}
226232
onChange={(value) => {

components/dashboard/src/user-settings/ShowTokenModal.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55
*/
66

77
import { PersonalAccessToken } from "@gitpod/public-api/lib/gitpod/experimental/v1/tokens_pb";
8-
import dayjs from "dayjs";
98
import { useState } from "react";
109
import DateSelector from "../components/DateSelector";
1110
import Modal, { ModalBody, ModalFooter, ModalHeader } from "../components/Modal";
12-
import { TokenExpirationDays } from "./PersonalAccessTokens";
11+
import { TokenExpirationDays, TokenExpirationForever, getTokenExpirationDescription } from "./PersonalAccessTokens";
1312
import { Button } from "@podkit/buttons/Button";
1413

1514
interface TokenModalProps {
@@ -19,12 +18,12 @@ interface TokenModalProps {
1918
descriptionImportant: string;
2019
actionDescription: string;
2120
showDateSelector?: boolean;
22-
onSave: (data: { expirationDate: Date }) => void;
21+
onSave: (data: { expirationDate: Date | null }) => void;
2322
onClose: () => void;
2423
}
2524

2625
function ShowTokenModal(props: TokenModalProps) {
27-
const [expiration, setExpiration] = useState({
26+
const [expiration, setExpiration] = useState<{ expirationDays: string; expirationDate: Date | null }>({
2827
expirationDays: "30",
2928
expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
3029
});
@@ -44,24 +43,29 @@ function ShowTokenModal(props: TokenModalProps) {
4443
<div className="p-4 mt-2 rounded-xl bg-gray-50 dark:bg-gray-800">
4544
<div className="font-semibold text-gray-700 dark:text-gray-200">{props.token.name}</div>
4645
<div className="font-medium text-gray-400 dark:text-gray-300">
47-
Expires on {dayjs(props.token.expirationTime!.toDate()).format("MMM D, YYYY")}
46+
{getTokenExpirationDescription(props.token.expirationTime?.toDate())}
4847
</div>
4948
</div>
5049
<div className="mt-4">
5150
{props.showDateSelector && (
5251
<DateSelector
5352
title="Expiration Date"
54-
description={`The token will expire on ${dayjs(expiration.expirationDate).format(
55-
"MMM D, YYYY",
56-
)}`}
53+
description={getTokenExpirationDescription(expiration.expirationDate)}
5754
options={TokenExpirationDays}
5855
value={TokenExpirationDays.find((i) => i.value === expiration.expirationDays)?.value}
59-
onChange={(value) =>
56+
onChange={(value) => {
57+
if (value === TokenExpirationForever) {
58+
setExpiration({
59+
expirationDays: value,
60+
expirationDate: null,
61+
});
62+
return;
63+
}
6064
setExpiration({
6165
expirationDays: value,
6266
expirationDate: new Date(Date.now() + Number(value) * 24 * 60 * 60 * 1000),
63-
})
64-
}
67+
});
68+
}}
6569
/>
6670
)}
6771
</div>

components/gitpod-db/go/personal_access_token.go

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ import (
1717
)
1818

1919
type PersonalAccessToken struct {
20-
ID uuid.UUID `gorm:"primary_key;column:id;type:varchar;size:255;" json:"id"`
21-
UserID uuid.UUID `gorm:"column:userId;type:varchar;size:255;" json:"userId"`
22-
Hash string `gorm:"column:hash;type:varchar;size:255;" json:"hash"`
23-
Name string `gorm:"column:name;type:varchar;size:255;" json:"name"`
24-
Scopes Scopes `gorm:"column:scopes;type:text;size:65535;" json:"scopes"`
25-
ExpirationTime time.Time `gorm:"column:expirationTime;type:timestamp;" json:"expirationTime"`
26-
CreatedAt time.Time `gorm:"column:createdAt;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"createdAt"`
27-
LastModified time.Time `gorm:"column:_lastModified;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"_lastModified"`
20+
ID uuid.UUID `gorm:"primary_key;column:id;type:varchar;size:255;" json:"id"`
21+
UserID uuid.UUID `gorm:"column:userId;type:varchar;size:255;" json:"userId"`
22+
Hash string `gorm:"column:hash;type:varchar;size:255;" json:"hash"`
23+
Name string `gorm:"column:name;type:varchar;size:255;" json:"name"`
24+
Scopes Scopes `gorm:"column:scopes;type:text;size:65535;" json:"scopes"`
25+
ExpirationTime *time.Time `gorm:"column:expirationTime;type:timestamp;" json:"expirationTime"`
26+
CreatedAt time.Time `gorm:"column:createdAt;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"createdAt"`
27+
LastModified time.Time `gorm:"column:_lastModified;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"_lastModified"`
2828

2929
// deleted is reserved for use by periodic deleter.
3030
_ bool `gorm:"column:deleted;type:tinyint;default:0;" json:"deleted"`
@@ -72,9 +72,6 @@ func CreatePersonalAccessToken(ctx context.Context, conn *gorm.DB, req PersonalA
7272
if req.Name == "" {
7373
return PersonalAccessToken{}, fmt.Errorf("Token name required")
7474
}
75-
if req.ExpirationTime.IsZero() {
76-
return PersonalAccessToken{}, fmt.Errorf("Expiration time required")
77-
}
7875

7976
now := time.Now().UTC()
8077
token := PersonalAccessToken{
@@ -96,7 +93,7 @@ func CreatePersonalAccessToken(ctx context.Context, conn *gorm.DB, req PersonalA
9693
return token, nil
9794
}
9895

99-
func UpdatePersonalAccessTokenHash(ctx context.Context, conn *gorm.DB, tokenID uuid.UUID, userID uuid.UUID, hash string, expirationTime time.Time) (PersonalAccessToken, error) {
96+
func UpdatePersonalAccessTokenHash(ctx context.Context, conn *gorm.DB, tokenID uuid.UUID, userID uuid.UUID, hash string, expirationTime *time.Time) (PersonalAccessToken, error) {
10097
if tokenID == uuid.Nil {
10198
return PersonalAccessToken{}, fmt.Errorf("Invalid or empty tokenID")
10299
}
@@ -106,9 +103,6 @@ func UpdatePersonalAccessTokenHash(ctx context.Context, conn *gorm.DB, tokenID u
106103
if hash == "" {
107104
return PersonalAccessToken{}, fmt.Errorf("Token hash required")
108105
}
109-
if expirationTime.IsZero() {
110-
return PersonalAccessToken{}, fmt.Errorf("Expiration time required")
111-
}
112106

113107
db := conn.WithContext(ctx)
114108

components/public-api-server/pkg/apiv1/tokens.go

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"regexp"
1212
"sort"
1313
"strings"
14+
"time"
1415

1516
connect "github.com/bufbuild/connect-go"
1617
"github.com/gitpod-io/gitpod/common-go/experiments"
@@ -54,9 +55,9 @@ func (s *TokensService) CreatePersonalAccessToken(ctx context.Context, req *conn
5455
return nil, err
5556
}
5657

57-
expiry := tokenReq.GetExpirationTime()
58-
if !expiry.IsValid() {
59-
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Received invalid Expiration Time, it is a required parameter."))
58+
expirationTime, err := validateNullableExpirationTime(req.Msg.GetExpirationTime())
59+
if err != nil {
60+
return nil, err
6061
}
6162

6263
scopes, err := validateScopes(tokenReq.GetScopes())
@@ -86,7 +87,7 @@ func (s *TokensService) CreatePersonalAccessToken(ctx context.Context, req *conn
8687
Hash: pat.ValueHash(),
8788
Name: name,
8889
Scopes: scopes,
89-
ExpirationTime: expiry.AsTime().UTC(),
90+
ExpirationTime: expirationTime,
9091
})
9192
if err != nil {
9293
log.Extract(ctx).WithError(err).Errorf("Failed to store personal access token for user %s", userID.String())
@@ -154,9 +155,9 @@ func (s *TokensService) RegeneratePersonalAccessToken(ctx context.Context, req *
154155
return nil, err
155156
}
156157

157-
expiry := req.Msg.GetExpirationTime()
158-
if !expiry.IsValid() {
159-
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Received invalid Expiration Time, it is a required parameter."))
158+
expirationTime, err := validateNullableExpirationTime(req.Msg.GetExpirationTime())
159+
if err != nil {
160+
return nil, err
160161
}
161162

162163
conn, err := getConnection(ctx, s.connectionPool)
@@ -175,7 +176,7 @@ func (s *TokensService) RegeneratePersonalAccessToken(ctx context.Context, req *
175176
}
176177

177178
hash := pat.ValueHash()
178-
token, err := db.UpdatePersonalAccessTokenHash(ctx, s.dbConn, tokenID, userID, hash, expiry.AsTime().UTC())
179+
token, err := db.UpdatePersonalAccessTokenHash(ctx, s.dbConn, tokenID, userID, hash, expirationTime)
179180
if err != nil {
180181
if errors.Is(err, db.ErrorNotFound) {
181182
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("Personal Access Token with ID %s for User %s does not exist", tokenID.String(), userID.String()))
@@ -363,15 +364,18 @@ func personalAccessTokensToAPI(ts []db.PersonalAccessToken) []*v1.PersonalAccess
363364
}
364365

365366
func personalAccessTokenToAPI(t db.PersonalAccessToken, value string) *v1.PersonalAccessToken {
366-
return &v1.PersonalAccessToken{
367+
tkn := &v1.PersonalAccessToken{
367368
Id: t.ID.String(),
368369
// value is only present when the token is first created, or regenerated. It's empty for all subsequent requests.
369-
Value: value,
370-
Name: t.Name,
371-
Scopes: t.Scopes,
372-
ExpirationTime: timestamppb.New(t.ExpirationTime),
373-
CreatedAt: timestamppb.New(t.CreatedAt),
370+
Value: value,
371+
Name: t.Name,
372+
Scopes: t.Scopes,
373+
CreatedAt: timestamppb.New(t.CreatedAt),
374+
}
375+
if t.ExpirationTime != nil {
376+
tkn.ExpirationTime = timestamppb.New(*t.ExpirationTime)
374377
}
378+
return tkn
375379
}
376380

377381
var (
@@ -415,3 +419,15 @@ func validateScopes(scopes []string) ([]string, error) {
415419

416420
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("Tokens can currently only have no scopes (empty), or all scopes represented as [%s, %s]", allFunctionsScope, defaultResourceScope))
417421
}
422+
423+
func validateNullableExpirationTime(expiry *timestamppb.Timestamp) (*time.Time, error) {
424+
if expiry != nil && !expiry.IsValid() {
425+
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Received invalid Expiration Time."))
426+
}
427+
var expirationTime *time.Time = nil
428+
if expiry != nil {
429+
tmp := expiry.AsTime().UTC()
430+
expirationTime = &tmp
431+
}
432+
return expirationTime, nil
433+
}

components/public-api/gitpod/experimental/v1/tokens.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ message PersonalAccessToken {
2525

2626
// expiration_time is the time when the token expires
2727
// Read only.
28+
// Null means no expiration_time
2829
google.protobuf.Timestamp expiration_time = 4;
2930

3031
// scopes are the permission scopes attached to this token.

components/public-api/go/experimental/v1/tokens.pb.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/public-api/typescript/src/gitpod/experimental/v1/tokens_pb.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)