Skip to content

Commit 400250a

Browse files
feat: add preferred_proxy to user account preferences
Moves workspace proxy selection from localStorage to user preferences API: - Add preferred_proxy user config queries to database - Create user proxy settings API endpoints (GET/PUT/DELETE /users/me/proxy) - Update ProxyContext to sync with user preferences instead of localStorage - Maintain backward compatibility with localStorage for migration - Update proxy selection UI to save to user account preferences This ensures proxy preferences persist across devices and browsers. Co-authored-by: kylecarbs <7122116+kylecarbs@users.noreply.github.com>
1 parent 183a6eb commit 400250a

File tree

7 files changed

+248
-14
lines changed

7 files changed

+248
-14
lines changed

coderd/coderd.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1267,6 +1267,9 @@ func New(options *Options) *API {
12671267
})
12681268
r.Get("/appearance", api.userAppearanceSettings)
12691269
r.Put("/appearance", api.putUserAppearanceSettings)
1270+
r.Get("/proxy", api.userProxySettings)
1271+
r.Put("/proxy", api.putUserProxySettings)
1272+
r.Delete("/proxy", api.deleteUserProxySettings)
12701273
r.Route("/password", func(r chi.Router) {
12711274
r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute))
12721275
r.Put("/", api.putUserPassword)

coderd/database/queries/users.sql

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,34 @@ WHERE user_configs.user_id = @user_id
148148
AND user_configs.key = 'terminal_font'
149149
RETURNING *;
150150

151+
-- name: GetUserPreferredProxy :one
152+
SELECT
153+
value as preferred_proxy
154+
FROM
155+
user_configs
156+
WHERE
157+
user_id = @user_id
158+
AND key = 'preferred_proxy';
159+
160+
-- name: UpdateUserPreferredProxy :one
161+
INSERT INTO
162+
user_configs (user_id, key, value)
163+
VALUES
164+
(@user_id, 'preferred_proxy', @preferred_proxy)
165+
ON CONFLICT
166+
ON CONSTRAINT user_configs_pkey
167+
DO UPDATE
168+
SET
169+
value = @preferred_proxy
170+
WHERE user_configs.user_id = @user_id
171+
AND user_configs.key = 'preferred_proxy'
172+
RETURNING *;
173+
174+
-- name: DeleteUserPreferredProxy :exec
175+
DELETE FROM user_configs
176+
WHERE user_id = @user_id
177+
AND key = 'preferred_proxy';
178+
151179
-- name: UpdateUserRoles :one
152180
UPDATE
153181
users

coderd/users.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,101 @@ func (api *API) putUserAppearanceSettings(rw http.ResponseWriter, r *http.Reques
10661066
})
10671067
}
10681068

1069+
// @Summary Get user proxy settings
1070+
// @ID get-user-proxy-settings
1071+
// @Security CoderSessionToken
1072+
// @Produce json
1073+
// @Tags Users
1074+
// @Param user path string true "User ID, name, or me"
1075+
// @Success 200 {object} codersdk.UserProxySettings
1076+
// @Router /users/{user}/proxy [get]
1077+
func (api *API) userProxySettings(rw http.ResponseWriter, r *http.Request) {
1078+
var (
1079+
ctx = r.Context()
1080+
user = httpmw.UserParam(r)
1081+
)
1082+
1083+
preferredProxy, err := api.Database.GetUserPreferredProxy(ctx, user.ID)
1084+
if err != nil {
1085+
if !errors.Is(err, sql.ErrNoRows) {
1086+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1087+
Message: "Error reading user proxy settings.",
1088+
Detail: err.Error(),
1089+
})
1090+
return
1091+
}
1092+
1093+
preferredProxy = ""
1094+
}
1095+
1096+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserProxySettings{
1097+
PreferredProxy: preferredProxy,
1098+
})
1099+
}
1100+
1101+
// @Summary Update user proxy settings
1102+
// @ID update-user-proxy-settings
1103+
// @Security CoderSessionToken
1104+
// @Accept json
1105+
// @Produce json
1106+
// @Tags Users
1107+
// @Param user path string true "User ID, name, or me"
1108+
// @Param request body codersdk.UpdateUserProxySettingsRequest true "New proxy settings"
1109+
// @Success 200 {object} codersdk.UserProxySettings
1110+
// @Router /users/{user}/proxy [put]
1111+
func (api *API) putUserProxySettings(rw http.ResponseWriter, r *http.Request) {
1112+
var (
1113+
ctx = r.Context()
1114+
user = httpmw.UserParam(r)
1115+
)
1116+
1117+
var params codersdk.UpdateUserProxySettingsRequest
1118+
if !httpapi.Read(ctx, rw, r, &params) {
1119+
return
1120+
}
1121+
1122+
updatedPreferredProxy, err := api.Database.UpdateUserPreferredProxy(ctx, database.UpdateUserPreferredProxyParams{
1123+
UserID: user.ID,
1124+
PreferredProxy: params.PreferredProxy,
1125+
})
1126+
if err != nil {
1127+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1128+
Message: "Internal error updating user proxy preference.",
1129+
Detail: err.Error(),
1130+
})
1131+
return
1132+
}
1133+
1134+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserProxySettings{
1135+
PreferredProxy: updatedPreferredProxy.Value,
1136+
})
1137+
}
1138+
1139+
// @Summary Delete user proxy settings
1140+
// @ID delete-user-proxy-settings
1141+
// @Security CoderSessionToken
1142+
// @Tags Users
1143+
// @Param user path string true "User ID, name, or me"
1144+
// @Success 204
1145+
// @Router /users/{user}/proxy [delete]
1146+
func (api *API) deleteUserProxySettings(rw http.ResponseWriter, r *http.Request) {
1147+
var (
1148+
ctx = r.Context()
1149+
user = httpmw.UserParam(r)
1150+
)
1151+
1152+
err := api.Database.DeleteUserPreferredProxy(ctx, user.ID)
1153+
if err != nil {
1154+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1155+
Message: "Internal error deleting user proxy preference.",
1156+
Detail: err.Error(),
1157+
})
1158+
return
1159+
}
1160+
1161+
rw.WriteHeader(http.StatusNoContent)
1162+
}
1163+
10691164
func isValidFontName(font codersdk.TerminalFontName) bool {
10701165
return slices.Contains(codersdk.TerminalFontNames, font)
10711166
}

codersdk/users.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,14 @@ type UpdateUserAppearanceSettingsRequest struct {
216216
TerminalFont TerminalFontName `json:"terminal_font" validate:"required"`
217217
}
218218

219+
type UserProxySettings struct {
220+
PreferredProxy string `json:"preferred_proxy"`
221+
}
222+
223+
type UpdateUserProxySettingsRequest struct {
224+
PreferredProxy string `json:"preferred_proxy" validate:"required"`
225+
}
226+
219227
type UpdateUserPasswordRequest struct {
220228
OldPassword string `json:"old_password" validate:""`
221229
Password string `json:"password" validate:"required"`
@@ -513,6 +521,47 @@ func (c *Client) UpdateUserAppearanceSettings(ctx context.Context, user string,
513521
return resp, json.NewDecoder(res.Body).Decode(&resp)
514522
}
515523

524+
// GetUserProxySettings fetches the proxy settings for a user.
525+
func (c *Client) GetUserProxySettings(ctx context.Context, user string) (UserProxySettings, error) {
526+
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/proxy", user), nil)
527+
if err != nil {
528+
return UserProxySettings{}, err
529+
}
530+
defer res.Body.Close()
531+
if res.StatusCode != http.StatusOK {
532+
return UserProxySettings{}, ReadBodyAsError(res)
533+
}
534+
var resp UserProxySettings
535+
return resp, json.NewDecoder(res.Body).Decode(&resp)
536+
}
537+
538+
// UpdateUserProxySettings updates the proxy settings for a user.
539+
func (c *Client) UpdateUserProxySettings(ctx context.Context, user string, req UpdateUserProxySettingsRequest) (UserProxySettings, error) {
540+
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/proxy", user), req)
541+
if err != nil {
542+
return UserProxySettings{}, err
543+
}
544+
defer res.Body.Close()
545+
if res.StatusCode != http.StatusOK {
546+
return UserProxySettings{}, ReadBodyAsError(res)
547+
}
548+
var resp UserProxySettings
549+
return resp, json.NewDecoder(res.Body).Decode(&resp)
550+
}
551+
552+
// DeleteUserProxySettings clears the proxy settings for a user.
553+
func (c *Client) DeleteUserProxySettings(ctx context.Context, user string) error {
554+
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s/proxy", user), nil)
555+
if err != nil {
556+
return err
557+
}
558+
defer res.Body.Close()
559+
if res.StatusCode != http.StatusNoContent {
560+
return ReadBodyAsError(res)
561+
}
562+
return nil
563+
}
564+
516565
// UpdateUserPassword updates a user password.
517566
// It calls PUT /users/{user}/password
518567
func (c *Client) UpdateUserPassword(ctx context.Context, user string, req UpdateUserPasswordRequest) error {

site/src/api/api.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,6 +1454,22 @@ class ApiMethods {
14541454
return response.data;
14551455
};
14561456

1457+
getProxySettings = async (): Promise<TypesGen.UserProxySettings> => {
1458+
const response = await this.axios.get("/api/v2/users/me/proxy");
1459+
return response.data;
1460+
};
1461+
1462+
updateProxySettings = async (
1463+
data: TypesGen.UpdateUserProxySettingsRequest,
1464+
): Promise<TypesGen.UserProxySettings> => {
1465+
const response = await this.axios.put("/api/v2/users/me/proxy", data);
1466+
return response.data;
1467+
};
1468+
1469+
deleteProxySettings = async (): Promise<void> => {
1470+
await this.axios.delete("/api/v2/users/me/proxy");
1471+
};
1472+
14571473
getUserQuietHoursSchedule = async (
14581474
userId: TypesGen.User["id"],
14591475
): Promise<TypesGen.UserQuietHoursScheduleResponse> => {

site/src/api/typesGenerated.ts

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

site/src/contexts/ProxyContext.tsx

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { cachedQuery } from "api/queries/util";
33
import type { Region, WorkspaceProxy } from "api/typesGenerated";
44
import { useAuthenticated } from "hooks";
55
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
6+
import { useMutation, useQuery, useQueryClient } from "react-query";
67
import {
78
type FC,
89
type PropsWithChildren,
910
createContext,
1011
useCallback,
1112
useContext,
1213
useEffect,
14+
useMemo,
1315
useState,
1416
} from "react";
1517
import { useQuery } from "react-query";
@@ -91,11 +93,42 @@ export const ProxyContext = createContext<ProxyContextValue | undefined>(
9193
* ProxyProvider interacts with local storage to indicate the preferred workspace proxy.
9294
*/
9395
export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
94-
// Using a useState so the caller always has the latest user saved
95-
// proxy.
96-
const [userSavedProxy, setUserSavedProxy] = useState(loadUserSelectedProxy());
96+
const queryClient = useQueryClient();
97+
98+
// Fetch user proxy settings from API
99+
const userProxyQuery = useQuery({
100+
queryKey: ["userProxySettings"],
101+
queryFn: () => API.getProxySettings(),
102+
retry: false, // Don't retry if user doesn't have proxy settings
103+
});
104+
105+
// Mutation for updating proxy settings
106+
const updateProxyMutation = useMutation({
107+
mutationFn: (proxyId: string) => API.updateProxySettings({ preferred_proxy: proxyId }),
108+
onSuccess: () => {
109+
queryClient.invalidateQueries(["userProxySettings"]);
110+
},
111+
});
112+
113+
const deleteProxyMutation = useMutation({
114+
mutationFn: () => API.deleteProxySettings(),
115+
onSuccess: () => {
116+
queryClient.invalidateQueries(["userProxySettings"]);
117+
},
118+
});
119+
120+
// Get user saved proxy from API or fallback to localStorage for migration
121+
const userSavedProxy = useMemo(() => {
122+
if (userProxyQuery.data?.preferred_proxy) {
123+
// Find the proxy object from the preferred_proxy ID
124+
const proxyId = userProxyQuery.data.preferred_proxy;
125+
return proxiesResp?.find(p => p.id === proxyId);
126+
}
127+
// Fallback to localStorage for migration
128+
return loadUserSelectedProxy();
129+
}, [userProxyQuery.data, proxiesResp]);
97130

98-
// Load the initial state from local storage.
131+
// Load the initial state from user preferences or localStorage.
99132
const [proxy, setProxy] = useState<PreferredProxy>(
100133
computeUsableURLS(userSavedProxy),
101134
);
@@ -134,27 +167,25 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
134167
// updateProxy is a helper function that when called will
135168
// update the proxy being used.
136169
const updateProxy = useCallback(() => {
137-
// Update the saved user proxy for the caller.
138-
setUserSavedProxy(loadUserSelectedProxy());
139170
setProxy(
140171
getPreferredProxy(
141172
proxiesResp ?? [],
142-
loadUserSelectedProxy(),
173+
userSavedProxy,
143174
proxyLatencies,
144175
// Do not auto select based on latencies, as inconsistent latencies can cause this
145176
// to change on each call. updateProxy should be stable when selecting a proxy to
146177
// prevent flickering.
147178
false,
148179
),
149180
);
150-
}, [proxiesResp, proxyLatencies]);
181+
}, [proxiesResp, proxyLatencies, userSavedProxy]);
151182

152183
// This useEffect ensures the proxy to be used is updated whenever the state changes.
153184
// This includes proxies being loaded, latencies being calculated, and the user selecting a proxy.
154185
// biome-ignore lint/correctness/useExhaustiveDependencies: Only update if the source data changes
155186
useEffect(() => {
156187
updateProxy();
157-
}, [proxiesResp, proxyLatencies]);
188+
}, [proxiesResp, proxyLatencies, userSavedProxy]);
158189

159190
// This useEffect will auto select the best proxy if the user has not selected one.
160191
// It must wait until all latencies are loaded to select based on latency. This does mean
@@ -163,7 +194,7 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
163194
// Once the page is loaded, or the user selects a proxy, this will not run again.
164195
// biome-ignore lint/correctness/useExhaustiveDependencies: Only update if the source data changes
165196
useEffect(() => {
166-
if (loadUserSelectedProxy() !== undefined) {
197+
if (userSavedProxy !== undefined) {
167198
return; // User has selected a proxy, do not auto select.
168199
}
169200
if (!latenciesLoaded) {
@@ -173,7 +204,7 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
173204

174205
const best = getPreferredProxy(
175206
proxiesResp ?? [],
176-
loadUserSelectedProxy(),
207+
userSavedProxy,
177208
proxyLatencies,
178209
true,
179210
);
@@ -199,13 +230,15 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
199230

200231
// These functions are exposed to allow the user to select a proxy.
201232
setProxy: (proxy: Region) => {
202-
// Save to local storage to persist the user's preference across reloads
203-
saveUserSelectedProxy(proxy);
233+
// Save to API and fallback to localStorage for immediate feedback
234+
updateProxyMutation.mutate(proxy.id);
235+
saveUserSelectedProxy(proxy); // Keep for immediate UI feedback
204236
// Update the selected proxy
205237
updateProxy();
206238
},
207239
clearProxy: () => {
208-
// Clear the user's selection from local storage.
240+
// Clear from API and localStorage
241+
deleteProxyMutation.mutate();
209242
clearUserSelectedProxy();
210243
updateProxy();
211244
},

0 commit comments

Comments
 (0)