Skip to content

Commit ba099b7

Browse files
committed
feat: update InstanceProfile to include initialization status
- Removed the owner field from InstanceProfile as it is no longer needed. - Added an initialized field to InstanceProfile to indicate if the instance has completed first-time setup. - Updated GetInstanceProfile method to set initialized based on the existence of an admin user. - Modified tests to reflect changes in InstanceProfile and ensure correct behavior regarding instance initialization. - Adjusted frontend logic to redirect users based on the initialized status instead of the owner field.
1 parent c240b70 commit ba099b7

25 files changed

+151
-73
lines changed

proto/api/v1/instance_service.proto

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,6 @@ service InstanceService {
3434

3535
// Instance profile message containing basic instance information.
3636
message InstanceProfile {
37-
// The name of instance owner.
38-
// Format: users/{user}
39-
string owner = 1;
40-
4137
// Version is the current version of instance.
4238
string version = 2;
4339

@@ -46,6 +42,11 @@ message InstanceProfile {
4642

4743
// Instance URL is the URL of the instance.
4844
string instance_url = 6;
45+
46+
// Indicates if the instance has completed first-time setup.
47+
// When false, the instance requires initialization (creating the first admin account).
48+
// This follows the pattern used by other self-hosted platforms for setup workflows.
49+
bool initialized = 7;
4950
}
5051

5152
// Request for instance profile.

proto/gen/api/v1/instance_service.pb.go

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

proto/gen/openapi.yaml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2132,11 +2132,6 @@ components:
21322132
InstanceProfile:
21332133
type: object
21342134
properties:
2135-
owner:
2136-
type: string
2137-
description: |-
2138-
The name of instance owner.
2139-
Format: users/{user}
21402135
version:
21412136
type: string
21422137
description: Version is the current version of instance.
@@ -2146,6 +2141,12 @@ components:
21462141
instanceUrl:
21472142
type: string
21482143
description: Instance URL is the URL of the instance.
2144+
initialized:
2145+
type: boolean
2146+
description: |-
2147+
Indicates if the instance has completed first-time setup.
2148+
When false, the instance requires initialization (creating the first admin account).
2149+
This follows the pattern used by other self-hosted platforms for setup workflows.
21492150
description: Instance profile message containing basic instance information.
21502151
InstanceSetting:
21512152
type: object

server/router/api/v1/instance_service.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,16 @@ import (
1515

1616
// GetInstanceProfile returns the instance profile.
1717
func (s *APIV1Service) GetInstanceProfile(ctx context.Context, _ *v1pb.GetInstanceProfileRequest) (*v1pb.InstanceProfile, error) {
18-
instanceProfile := &v1pb.InstanceProfile{
19-
Version: s.Profile.Version,
20-
Demo: s.Profile.Demo,
21-
InstanceUrl: s.Profile.InstanceURL,
22-
}
2318
owner, err := s.GetInstanceOwner(ctx)
2419
if err != nil {
2520
return nil, status.Errorf(codes.Internal, "failed to get instance owner: %v", err)
2621
}
27-
if owner != nil {
28-
instanceProfile.Owner = owner.Name
22+
23+
instanceProfile := &v1pb.InstanceProfile{
24+
Version: s.Profile.Version,
25+
Demo: s.Profile.Demo,
26+
InstanceUrl: s.Profile.InstanceURL,
27+
Initialized: owner != nil,
2928
}
3029
return instanceProfile, nil
3130
}
@@ -291,3 +290,9 @@ func (s *APIV1Service) GetInstanceOwner(ctx context.Context) (*v1pb.User, error)
291290
ownerCache = convertUserFromStore(user)
292291
return ownerCache, nil
293292
}
293+
294+
// ClearInstanceOwnerCache clears the cached instance owner.
295+
// This should be called when an admin user is created or when the owner changes.
296+
func (s *APIV1Service) ClearInstanceOwnerCache() {
297+
ownerCache = nil
298+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
v1pb "github.com/usememos/memos/proto/gen/api/v1"
10+
)
11+
12+
func TestInstanceOwnerCache(t *testing.T) {
13+
ctx := context.Background()
14+
15+
t.Run("Instance becomes initialized after first admin user is created", func(t *testing.T) {
16+
// Create test service
17+
ts := NewTestService(t)
18+
defer ts.Cleanup()
19+
20+
// Verify instance is not initialized initially
21+
profile1, err := ts.Service.GetInstanceProfile(ctx, &v1pb.GetInstanceProfileRequest{})
22+
require.NoError(t, err)
23+
require.False(t, profile1.Initialized, "Instance should not be initialized before first admin user")
24+
25+
// Create the first admin user
26+
user, err := ts.CreateHostUser(ctx, "admin")
27+
require.NoError(t, err)
28+
require.NotNil(t, user)
29+
30+
// Verify instance is now initialized
31+
profile2, err := ts.Service.GetInstanceProfile(ctx, &v1pb.GetInstanceProfileRequest{})
32+
require.NoError(t, err)
33+
require.True(t, profile2.Initialized, "Instance should be initialized after first admin user is created")
34+
})
35+
36+
t.Run("ClearInstanceOwnerCache works correctly", func(t *testing.T) {
37+
// Create test service
38+
ts := NewTestService(t)
39+
defer ts.Cleanup()
40+
41+
// Create admin user
42+
_, err := ts.CreateHostUser(ctx, "admin")
43+
require.NoError(t, err)
44+
45+
// Verify initialized
46+
profile1, err := ts.Service.GetInstanceProfile(ctx, &v1pb.GetInstanceProfileRequest{})
47+
require.NoError(t, err)
48+
require.True(t, profile1.Initialized)
49+
50+
// Clear cache
51+
ts.Service.ClearInstanceOwnerCache()
52+
53+
// Should still be initialized (cache is refilled from DB)
54+
profile2, err := ts.Service.GetInstanceProfile(ctx, &v1pb.GetInstanceProfileRequest{})
55+
require.NoError(t, err)
56+
require.True(t, profile2.Initialized)
57+
})
58+
}

server/router/api/v1/test/instance_service_test.go

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package test
22

33
import (
44
"context"
5-
"fmt"
65
"testing"
76

87
"github.com/stretchr/testify/require"
@@ -31,11 +30,11 @@ func TestGetInstanceProfile(t *testing.T) {
3130
require.True(t, resp.Demo)
3231
require.Equal(t, "http://localhost:8080", resp.InstanceUrl)
3332

34-
// Owner should be empty since no users are created
35-
require.Empty(t, resp.Owner)
33+
// Instance should not be initialized since no admin users are created
34+
require.False(t, resp.Initialized)
3635
})
3736

38-
t.Run("GetInstanceProfile with owner", func(t *testing.T) {
37+
t.Run("GetInstanceProfile with initialized instance", func(t *testing.T) {
3938
// Create test service for this specific test
4039
ts := NewTestService(t)
4140
defer ts.Cleanup()
@@ -53,14 +52,13 @@ func TestGetInstanceProfile(t *testing.T) {
5352
require.NoError(t, err)
5453
require.NotNil(t, resp)
5554

56-
// Verify the response contains expected data including owner
55+
// Verify the response contains expected data with initialized flag
5756
require.Equal(t, "test-1.0.0", resp.Version)
5857
require.True(t, resp.Demo)
5958
require.Equal(t, "http://localhost:8080", resp.InstanceUrl)
6059

61-
// User name should be "users/{id}" format where id is the user's ID
62-
expectedOwnerName := fmt.Sprintf("users/%d", hostUser.ID)
63-
require.Equal(t, expectedOwnerName, resp.Owner)
60+
// Instance should be initialized since an admin user exists
61+
require.True(t, resp.Initialized)
6462
})
6563
}
6664

@@ -73,9 +71,8 @@ func TestGetInstanceProfile_Concurrency(t *testing.T) {
7371
defer ts.Cleanup()
7472

7573
// Create a host user
76-
hostUser, err := ts.CreateHostUser(ctx, "admin")
74+
_, err := ts.CreateHostUser(ctx, "admin")
7775
require.NoError(t, err)
78-
expectedOwnerName := fmt.Sprintf("users/%d", hostUser.ID)
7976

8077
// Make concurrent requests
8178
numGoroutines := 10
@@ -104,7 +101,7 @@ func TestGetInstanceProfile_Concurrency(t *testing.T) {
104101
require.Equal(t, "test-1.0.0", resp.Version)
105102
require.True(t, resp.Demo)
106103
require.Equal(t, "http://localhost:8080", resp.InstanceUrl)
107-
require.Equal(t, expectedOwnerName, resp.Owner)
104+
require.True(t, resp.Initialized)
108105
}
109106
}
110107
})

server/router/api/v1/user_service.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,12 @@ func (s *APIV1Service) CreateUser(ctx context.Context, request *v1pb.CreateUserR
177177
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
178178
}
179179

180+
// If this is the first admin user being created, clear the owner cache
181+
// so that GetInstanceProfile will return initialized=true
182+
if roleToAssign == store.RoleAdmin {
183+
s.ClearInstanceOwnerCache()
184+
}
185+
180186
return convertUserFromStore(user), nil
181187
}
182188

web/src/App.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ const App = () => {
2020
cleanupExpiredOAuthState();
2121
}, []);
2222

23-
// Redirect to sign up page if no instance owner
23+
// Redirect to sign up page if instance not initialized (no admin account exists yet)
2424
useEffect(() => {
25-
if (!instanceProfile.owner) {
25+
if (!instanceProfile.initialized) {
2626
navigateTo("/auth/signup");
2727
}
28-
}, [instanceProfile.owner, navigateTo]);
28+
}, [instanceProfile.initialized, navigateTo]);
2929

3030
useEffect(() => {
3131
if (instanceGeneralSetting.additionalStyle) {

web/src/pages/Home.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { MemoRenderContext } from "@/components/MasonryView";
22
import MemoView from "@/components/MemoView";
33
import PagedMemoList from "@/components/PagedMemoList";
4+
import { useInstance } from "@/contexts/InstanceContext";
45
import { useMemoFilters, useMemoSorting } from "@/hooks";
56
import useCurrentUser from "@/hooks/useCurrentUser";
6-
import { useInstance } from "@/contexts/InstanceContext";
77
import { State } from "@/types/proto/api/v1/common_pb";
88
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
99

web/src/pages/SignUp.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,22 @@ import AuthFooter from "@/components/AuthFooter";
99
import { Button } from "@/components/ui/button";
1010
import { Input } from "@/components/ui/input";
1111
import { authServiceClient, userServiceClient } from "@/connect";
12+
import { useAuth } from "@/contexts/AuthContext";
1213
import { useInstance } from "@/contexts/InstanceContext";
1314
import useLoading from "@/hooks/useLoading";
15+
import useNavigateTo from "@/hooks/useNavigateTo";
1416
import { handleError } from "@/lib/error";
1517
import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
1618
import { useTranslate } from "@/utils/i18n";
1719

1820
const SignUp = () => {
1921
const t = useTranslate();
22+
const navigateTo = useNavigateTo();
2023
const actionBtnLoadingState = useLoading(false);
2124
const [username, setUsername] = useState("");
2225
const [password, setPassword] = useState("");
23-
const { generalSetting: instanceGeneralSetting, profile } = useInstance();
26+
const { initialize: initAuth } = useAuth();
27+
const { generalSetting: instanceGeneralSetting, profile, initialize: initInstance } = useInstance();
2428

2529
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
2630
const text = e.target.value as string;
@@ -64,7 +68,11 @@ const SignUp = () => {
6468
if (response.accessToken) {
6569
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
6670
}
67-
window.location.href = "/";
71+
// Refresh auth context to load the current user
72+
await initAuth();
73+
// Refetch instance profile to update the initialized status
74+
await initInstance();
75+
navigateTo("/");
6876
} catch (error: unknown) {
6977
handleError(error, toast.error, {
7078
fallbackMessage: "Sign up failed",
@@ -127,7 +135,7 @@ const SignUp = () => {
127135
) : (
128136
<p className="w-full text-2xl mt-2 text-muted-foreground">Sign up is not allowed.</p>
129137
)}
130-
{!profile.owner ? (
138+
{!profile.initialized ? (
131139
<p className="w-full mt-4 text-sm font-medium text-muted-foreground">{t("auth.host-tip")}</p>
132140
) : (
133141
<p className="w-full mt-4 text-sm">

0 commit comments

Comments
 (0)