diff --git a/README.md b/README.md index 1259588..11a5ab7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ ### Bugs - [ ] deleting a song doesnt actually delete the file from the disk -- [ ] in channel it should remove a song not delete ### Other - [ ] otel instrumentation diff --git a/backend/gen/radio/v1/channel.pb.go b/backend/gen/radio/v1/channel.pb.go index 5556c12..7e5e1f3 100644 --- a/backend/gen/radio/v1/channel.pb.go +++ b/backend/gen/radio/v1/channel.pb.go @@ -32,6 +32,7 @@ type Channel struct { Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` CreatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` ModifiedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=modified_at,json=modifiedAt,proto3" json:"modified_at,omitempty"` + EditorIds []string `protobuf:"bytes,7,rep,name=editor_ids,json=editorIds,proto3" json:"editor_ids,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -108,6 +109,13 @@ func (x *Channel) GetModifiedAt() *timestamppb.Timestamp { return nil } +func (x *Channel) GetEditorIds() []string { + if x != nil { + return x.EditorIds + } + return nil +} + type CreateChannelRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Frequency string `protobuf:"bytes,1,opt,name=frequency,proto3" json:"frequency,omitempty"` @@ -484,11 +492,63 @@ func (x *ChannelGetSongsResponse) GetSongs() []*Song { return nil } +type ChannelAddEditorRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ChannelId string `protobuf:"bytes,2,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChannelAddEditorRequest) Reset() { + *x = ChannelAddEditorRequest{} + mi := &file_radio_v1_channel_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChannelAddEditorRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChannelAddEditorRequest) ProtoMessage() {} + +func (x *ChannelAddEditorRequest) ProtoReflect() protoreflect.Message { + mi := &file_radio_v1_channel_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChannelAddEditorRequest.ProtoReflect.Descriptor instead. +func (*ChannelAddEditorRequest) Descriptor() ([]byte, []int) { + return file_radio_v1_channel_proto_rawDescGZIP(), []int{9} +} + +func (x *ChannelAddEditorRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ChannelAddEditorRequest) GetChannelId() string { + if x != nil { + return x.ChannelId + } + return "" +} + var File_radio_v1_channel_proto protoreflect.FileDescriptor const file_radio_v1_channel_proto_rawDesc = "" + "\n" + - "\x16radio/v1/channel.proto\x12\bradio.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x13radio/v1/song.proto\"\x80\x02\n" + + "\x16radio/v1/channel.proto\x12\bradio.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x13radio/v1/song.proto\"\x9f\x02\n" + "\aChannel\x12\x18\n" + "\x02id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x02id\x12#\n" + "\bowner_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\aownerId\x12\x1c\n" + @@ -497,7 +557,9 @@ const file_radio_v1_channel_proto_rawDesc = "" + "\n" + "created_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12;\n" + "\vmodified_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "modifiedAt\"V\n" + + "modifiedAt\x12\x1d\n" + + "\n" + + "editor_ids\x18\a \x03(\tR\teditorIds\"V\n" + "\x14CreateChannelRequest\x12\x1c\n" + "\tfrequency\x18\x01 \x01(\tR\tfrequency\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\"D\n" + @@ -519,14 +581,19 @@ const file_radio_v1_channel_proto_rawDesc = "" + "\n" + "channel_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tchannelId\"?\n" + "\x17ChannelGetSongsResponse\x12$\n" + - "\x05songs\x18\x01 \x03(\v2\x0e.radio.v1.SongR\x05songs2\xe5\x03\n" + + "\x05songs\x18\x01 \x03(\v2\x0e.radio.v1.SongR\x05songs\"e\n" + + "\x17ChannelAddEditorRequest\x12!\n" + + "\auser_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06userId\x12'\n" + + "\n" + + "channel_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tchannelId2\xb4\x04\n" + "\x0eChannelService\x12P\n" + "\rCreateChannel\x12\x1e.radio.v1.CreateChannelRequest\x1a\x1f.radio.v1.CreateChannelResponse\x12G\n" + "\rDeleteChannel\x12\x1e.radio.v1.DeleteChannelRequest\x1a\x16.google.protobuf.Empty\x12D\n" + "\vChannelList\x12\x16.google.protobuf.Empty\x1a\x1d.radio.v1.ChannelListResponse\x12I\n" + "\x0eChannelAddSong\x12\x1f.radio.v1.ChannelAddSongRequest\x1a\x16.google.protobuf.Empty\x12O\n" + "\x11ChannelRemoveSong\x12\".radio.v1.ChannelRemoveSongRequest\x1a\x16.google.protobuf.Empty\x12V\n" + - "\x0fChannelGetSongs\x12 .radio.v1.ChannelGetSongsRequest\x1a!.radio.v1.ChannelGetSongsResponseB\x85\x01\n" + + "\x0fChannelGetSongs\x12 .radio.v1.ChannelGetSongsRequest\x1a!.radio.v1.ChannelGetSongsResponse\x12M\n" + + "\x10ChannelAddEditor\x12!.radio.v1.ChannelAddEditorRequest\x1a\x16.google.protobuf.EmptyB\x85\x01\n" + "\fcom.radio.v1B\fChannelProtoP\x01Z&echsen.club/radio/gen/radio/v1;radiov1\xa2\x02\x03RXX\xaa\x02\bRadio.V1\xca\x02\bRadio\\V1\xe2\x02\x14Radio\\V1\\GPBMetadata\xea\x02\tRadio::V1b\x06proto3" var ( @@ -541,7 +608,7 @@ func file_radio_v1_channel_proto_rawDescGZIP() []byte { return file_radio_v1_channel_proto_rawDescData } -var file_radio_v1_channel_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_radio_v1_channel_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_radio_v1_channel_proto_goTypes = []any{ (*Channel)(nil), // 0: radio.v1.Channel (*CreateChannelRequest)(nil), // 1: radio.v1.CreateChannelRequest @@ -552,30 +619,33 @@ var file_radio_v1_channel_proto_goTypes = []any{ (*ChannelRemoveSongRequest)(nil), // 6: radio.v1.ChannelRemoveSongRequest (*ChannelGetSongsRequest)(nil), // 7: radio.v1.ChannelGetSongsRequest (*ChannelGetSongsResponse)(nil), // 8: radio.v1.ChannelGetSongsResponse - (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp - (*Song)(nil), // 10: radio.v1.Song - (*emptypb.Empty)(nil), // 11: google.protobuf.Empty + (*ChannelAddEditorRequest)(nil), // 9: radio.v1.ChannelAddEditorRequest + (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp + (*Song)(nil), // 11: radio.v1.Song + (*emptypb.Empty)(nil), // 12: google.protobuf.Empty } var file_radio_v1_channel_proto_depIdxs = []int32{ - 9, // 0: radio.v1.Channel.created_at:type_name -> google.protobuf.Timestamp - 9, // 1: radio.v1.Channel.modified_at:type_name -> google.protobuf.Timestamp + 10, // 0: radio.v1.Channel.created_at:type_name -> google.protobuf.Timestamp + 10, // 1: radio.v1.Channel.modified_at:type_name -> google.protobuf.Timestamp 0, // 2: radio.v1.CreateChannelResponse.channel:type_name -> radio.v1.Channel 0, // 3: radio.v1.ChannelListResponse.channels:type_name -> radio.v1.Channel - 10, // 4: radio.v1.ChannelGetSongsResponse.songs:type_name -> radio.v1.Song + 11, // 4: radio.v1.ChannelGetSongsResponse.songs:type_name -> radio.v1.Song 1, // 5: radio.v1.ChannelService.CreateChannel:input_type -> radio.v1.CreateChannelRequest 3, // 6: radio.v1.ChannelService.DeleteChannel:input_type -> radio.v1.DeleteChannelRequest - 11, // 7: radio.v1.ChannelService.ChannelList:input_type -> google.protobuf.Empty + 12, // 7: radio.v1.ChannelService.ChannelList:input_type -> google.protobuf.Empty 5, // 8: radio.v1.ChannelService.ChannelAddSong:input_type -> radio.v1.ChannelAddSongRequest 6, // 9: radio.v1.ChannelService.ChannelRemoveSong:input_type -> radio.v1.ChannelRemoveSongRequest 7, // 10: radio.v1.ChannelService.ChannelGetSongs:input_type -> radio.v1.ChannelGetSongsRequest - 2, // 11: radio.v1.ChannelService.CreateChannel:output_type -> radio.v1.CreateChannelResponse - 11, // 12: radio.v1.ChannelService.DeleteChannel:output_type -> google.protobuf.Empty - 4, // 13: radio.v1.ChannelService.ChannelList:output_type -> radio.v1.ChannelListResponse - 11, // 14: radio.v1.ChannelService.ChannelAddSong:output_type -> google.protobuf.Empty - 11, // 15: radio.v1.ChannelService.ChannelRemoveSong:output_type -> google.protobuf.Empty - 8, // 16: radio.v1.ChannelService.ChannelGetSongs:output_type -> radio.v1.ChannelGetSongsResponse - 11, // [11:17] is the sub-list for method output_type - 5, // [5:11] is the sub-list for method input_type + 9, // 11: radio.v1.ChannelService.ChannelAddEditor:input_type -> radio.v1.ChannelAddEditorRequest + 2, // 12: radio.v1.ChannelService.CreateChannel:output_type -> radio.v1.CreateChannelResponse + 12, // 13: radio.v1.ChannelService.DeleteChannel:output_type -> google.protobuf.Empty + 4, // 14: radio.v1.ChannelService.ChannelList:output_type -> radio.v1.ChannelListResponse + 12, // 15: radio.v1.ChannelService.ChannelAddSong:output_type -> google.protobuf.Empty + 12, // 16: radio.v1.ChannelService.ChannelRemoveSong:output_type -> google.protobuf.Empty + 8, // 17: radio.v1.ChannelService.ChannelGetSongs:output_type -> radio.v1.ChannelGetSongsResponse + 12, // 18: radio.v1.ChannelService.ChannelAddEditor:output_type -> google.protobuf.Empty + 12, // [12:19] is the sub-list for method output_type + 5, // [5:12] is the sub-list for method input_type 5, // [5:5] is the sub-list for extension type_name 5, // [5:5] is the sub-list for extension extendee 0, // [0:5] is the sub-list for field type_name @@ -593,7 +663,7 @@ func file_radio_v1_channel_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_radio_v1_channel_proto_rawDesc), len(file_radio_v1_channel_proto_rawDesc)), NumEnums: 0, - NumMessages: 9, + NumMessages: 10, NumExtensions: 0, NumServices: 1, }, diff --git a/backend/gen/radio/v1/radiov1connect/channel.connect.go b/backend/gen/radio/v1/radiov1connect/channel.connect.go index 1ce6be5..38107d2 100644 --- a/backend/gen/radio/v1/radiov1connect/channel.connect.go +++ b/backend/gen/radio/v1/radiov1connect/channel.connect.go @@ -52,6 +52,9 @@ const ( // ChannelServiceChannelGetSongsProcedure is the fully-qualified name of the ChannelService's // ChannelGetSongs RPC. ChannelServiceChannelGetSongsProcedure = "/radio.v1.ChannelService/ChannelGetSongs" + // ChannelServiceChannelAddEditorProcedure is the fully-qualified name of the ChannelService's + // ChannelAddEditor RPC. + ChannelServiceChannelAddEditorProcedure = "/radio.v1.ChannelService/ChannelAddEditor" ) // ChannelServiceClient is a client for the radio.v1.ChannelService service. @@ -62,6 +65,7 @@ type ChannelServiceClient interface { ChannelAddSong(context.Context, *v1.ChannelAddSongRequest) (*emptypb.Empty, error) ChannelRemoveSong(context.Context, *v1.ChannelRemoveSongRequest) (*emptypb.Empty, error) ChannelGetSongs(context.Context, *v1.ChannelGetSongsRequest) (*v1.ChannelGetSongsResponse, error) + ChannelAddEditor(context.Context, *v1.ChannelAddEditorRequest) (*emptypb.Empty, error) } // NewChannelServiceClient constructs a client for the radio.v1.ChannelService service. By default, @@ -111,6 +115,12 @@ func NewChannelServiceClient(httpClient connect.HTTPClient, baseURL string, opts connect.WithSchema(channelServiceMethods.ByName("ChannelGetSongs")), connect.WithClientOptions(opts...), ), + channelAddEditor: connect.NewClient[v1.ChannelAddEditorRequest, emptypb.Empty]( + httpClient, + baseURL+ChannelServiceChannelAddEditorProcedure, + connect.WithSchema(channelServiceMethods.ByName("ChannelAddEditor")), + connect.WithClientOptions(opts...), + ), } } @@ -122,6 +132,7 @@ type channelServiceClient struct { channelAddSong *connect.Client[v1.ChannelAddSongRequest, emptypb.Empty] channelRemoveSong *connect.Client[v1.ChannelRemoveSongRequest, emptypb.Empty] channelGetSongs *connect.Client[v1.ChannelGetSongsRequest, v1.ChannelGetSongsResponse] + channelAddEditor *connect.Client[v1.ChannelAddEditorRequest, emptypb.Empty] } // CreateChannel calls radio.v1.ChannelService.CreateChannel. @@ -178,6 +189,15 @@ func (c *channelServiceClient) ChannelGetSongs(ctx context.Context, req *v1.Chan return nil, err } +// ChannelAddEditor calls radio.v1.ChannelService.ChannelAddEditor. +func (c *channelServiceClient) ChannelAddEditor(ctx context.Context, req *v1.ChannelAddEditorRequest) (*emptypb.Empty, error) { + response, err := c.channelAddEditor.CallUnary(ctx, connect.NewRequest(req)) + if response != nil { + return response.Msg, err + } + return nil, err +} + // ChannelServiceHandler is an implementation of the radio.v1.ChannelService service. type ChannelServiceHandler interface { CreateChannel(context.Context, *v1.CreateChannelRequest) (*v1.CreateChannelResponse, error) @@ -186,6 +206,7 @@ type ChannelServiceHandler interface { ChannelAddSong(context.Context, *v1.ChannelAddSongRequest) (*emptypb.Empty, error) ChannelRemoveSong(context.Context, *v1.ChannelRemoveSongRequest) (*emptypb.Empty, error) ChannelGetSongs(context.Context, *v1.ChannelGetSongsRequest) (*v1.ChannelGetSongsResponse, error) + ChannelAddEditor(context.Context, *v1.ChannelAddEditorRequest) (*emptypb.Empty, error) } // NewChannelServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -231,6 +252,12 @@ func NewChannelServiceHandler(svc ChannelServiceHandler, opts ...connect.Handler connect.WithSchema(channelServiceMethods.ByName("ChannelGetSongs")), connect.WithHandlerOptions(opts...), ) + channelServiceChannelAddEditorHandler := connect.NewUnaryHandlerSimple( + ChannelServiceChannelAddEditorProcedure, + svc.ChannelAddEditor, + connect.WithSchema(channelServiceMethods.ByName("ChannelAddEditor")), + connect.WithHandlerOptions(opts...), + ) return "/radio.v1.ChannelService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case ChannelServiceCreateChannelProcedure: @@ -245,6 +272,8 @@ func NewChannelServiceHandler(svc ChannelServiceHandler, opts ...connect.Handler channelServiceChannelRemoveSongHandler.ServeHTTP(w, r) case ChannelServiceChannelGetSongsProcedure: channelServiceChannelGetSongsHandler.ServeHTTP(w, r) + case ChannelServiceChannelAddEditorProcedure: + channelServiceChannelAddEditorHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -277,3 +306,7 @@ func (UnimplementedChannelServiceHandler) ChannelRemoveSong(context.Context, *v1 func (UnimplementedChannelServiceHandler) ChannelGetSongs(context.Context, *v1.ChannelGetSongsRequest) (*v1.ChannelGetSongsResponse, error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("radio.v1.ChannelService.ChannelGetSongs is not implemented")) } + +func (UnimplementedChannelServiceHandler) ChannelAddEditor(context.Context, *v1.ChannelAddEditorRequest) (*emptypb.Empty, error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("radio.v1.ChannelService.ChannelAddEditor is not implemented")) +} diff --git a/backend/gen/radio/v1/radiov1connect/user.connect.go b/backend/gen/radio/v1/radiov1connect/user.connect.go index e830c24..6a7bce8 100644 --- a/backend/gen/radio/v1/radiov1connect/user.connect.go +++ b/backend/gen/radio/v1/radiov1connect/user.connect.go @@ -37,11 +37,14 @@ const ( // UserServiceGetCurrentUserProcedure is the fully-qualified name of the UserService's // GetCurrentUser RPC. UserServiceGetCurrentUserProcedure = "/radio.v1.UserService/GetCurrentUser" + // UserServiceListUsersProcedure is the fully-qualified name of the UserService's ListUsers RPC. + UserServiceListUsersProcedure = "/radio.v1.UserService/ListUsers" ) // UserServiceClient is a client for the radio.v1.UserService service. type UserServiceClient interface { GetCurrentUser(context.Context, *emptypb.Empty) (*v1.GetCurrentUserResponse, error) + ListUsers(context.Context, *emptypb.Empty) (*v1.ListUsersResponse, error) } // NewUserServiceClient constructs a client for the radio.v1.UserService service. By default, it @@ -61,12 +64,19 @@ func NewUserServiceClient(httpClient connect.HTTPClient, baseURL string, opts .. connect.WithSchema(userServiceMethods.ByName("GetCurrentUser")), connect.WithClientOptions(opts...), ), + listUsers: connect.NewClient[emptypb.Empty, v1.ListUsersResponse]( + httpClient, + baseURL+UserServiceListUsersProcedure, + connect.WithSchema(userServiceMethods.ByName("ListUsers")), + connect.WithClientOptions(opts...), + ), } } // userServiceClient implements UserServiceClient. type userServiceClient struct { getCurrentUser *connect.Client[emptypb.Empty, v1.GetCurrentUserResponse] + listUsers *connect.Client[emptypb.Empty, v1.ListUsersResponse] } // GetCurrentUser calls radio.v1.UserService.GetCurrentUser. @@ -78,9 +88,19 @@ func (c *userServiceClient) GetCurrentUser(ctx context.Context, req *emptypb.Emp return nil, err } +// ListUsers calls radio.v1.UserService.ListUsers. +func (c *userServiceClient) ListUsers(ctx context.Context, req *emptypb.Empty) (*v1.ListUsersResponse, error) { + response, err := c.listUsers.CallUnary(ctx, connect.NewRequest(req)) + if response != nil { + return response.Msg, err + } + return nil, err +} + // UserServiceHandler is an implementation of the radio.v1.UserService service. type UserServiceHandler interface { GetCurrentUser(context.Context, *emptypb.Empty) (*v1.GetCurrentUserResponse, error) + ListUsers(context.Context, *emptypb.Empty) (*v1.ListUsersResponse, error) } // NewUserServiceHandler builds an HTTP handler from the service implementation. It returns the path @@ -96,10 +116,18 @@ func NewUserServiceHandler(svc UserServiceHandler, opts ...connect.HandlerOption connect.WithSchema(userServiceMethods.ByName("GetCurrentUser")), connect.WithHandlerOptions(opts...), ) + userServiceListUsersHandler := connect.NewUnaryHandlerSimple( + UserServiceListUsersProcedure, + svc.ListUsers, + connect.WithSchema(userServiceMethods.ByName("ListUsers")), + connect.WithHandlerOptions(opts...), + ) return "/radio.v1.UserService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case UserServiceGetCurrentUserProcedure: userServiceGetCurrentUserHandler.ServeHTTP(w, r) + case UserServiceListUsersProcedure: + userServiceListUsersHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -112,3 +140,7 @@ type UnimplementedUserServiceHandler struct{} func (UnimplementedUserServiceHandler) GetCurrentUser(context.Context, *emptypb.Empty) (*v1.GetCurrentUserResponse, error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("radio.v1.UserService.GetCurrentUser is not implemented")) } + +func (UnimplementedUserServiceHandler) ListUsers(context.Context, *emptypb.Empty) (*v1.ListUsersResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("radio.v1.UserService.ListUsers is not implemented")) +} diff --git a/backend/gen/radio/v1/user.pb.go b/backend/gen/radio/v1/user.pb.go index 6aa36b0..09631e1 100644 --- a/backend/gen/radio/v1/user.pb.go +++ b/backend/gen/radio/v1/user.pb.go @@ -127,6 +127,50 @@ func (x *GetCurrentUserResponse) GetUser() *User { return nil } +type ListUsersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Users []*User `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListUsersResponse) Reset() { + *x = ListUsersResponse{} + mi := &file_radio_v1_user_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListUsersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListUsersResponse) ProtoMessage() {} + +func (x *ListUsersResponse) ProtoReflect() protoreflect.Message { + mi := &file_radio_v1_user_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListUsersResponse.ProtoReflect.Descriptor instead. +func (*ListUsersResponse) Descriptor() ([]byte, []int) { + return file_radio_v1_user_proto_rawDescGZIP(), []int{2} +} + +func (x *ListUsersResponse) GetUsers() []*User { + if x != nil { + return x.Users + } + return nil +} + var File_radio_v1_user_proto protoreflect.FileDescriptor const file_radio_v1_user_proto_rawDesc = "" + @@ -137,9 +181,12 @@ const file_radio_v1_user_proto_rawDesc = "" + "\x05email\x18\x02 \x01(\tR\x05email\x12!\n" + "\fdisplay_name\x18\x03 \x01(\tR\vdisplayName\"<\n" + "\x16GetCurrentUserResponse\x12\"\n" + - "\x04user\x18\x01 \x01(\v2\x0e.radio.v1.UserR\x04user2Y\n" + + "\x04user\x18\x01 \x01(\v2\x0e.radio.v1.UserR\x04user\"9\n" + + "\x11ListUsersResponse\x12$\n" + + "\x05users\x18\x01 \x03(\v2\x0e.radio.v1.UserR\x05users2\x9b\x01\n" + "\vUserService\x12J\n" + - "\x0eGetCurrentUser\x12\x16.google.protobuf.Empty\x1a .radio.v1.GetCurrentUserResponseB\x82\x01\n" + + "\x0eGetCurrentUser\x12\x16.google.protobuf.Empty\x1a .radio.v1.GetCurrentUserResponse\x12@\n" + + "\tListUsers\x12\x16.google.protobuf.Empty\x1a\x1b.radio.v1.ListUsersResponseB\x82\x01\n" + "\fcom.radio.v1B\tUserProtoP\x01Z&echsen.club/radio/gen/radio/v1;radiov1\xa2\x02\x03RXX\xaa\x02\bRadio.V1\xca\x02\bRadio\\V1\xe2\x02\x14Radio\\V1\\GPBMetadata\xea\x02\tRadio::V1b\x06proto3" var ( @@ -154,21 +201,25 @@ func file_radio_v1_user_proto_rawDescGZIP() []byte { return file_radio_v1_user_proto_rawDescData } -var file_radio_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_radio_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_radio_v1_user_proto_goTypes = []any{ (*User)(nil), // 0: radio.v1.User (*GetCurrentUserResponse)(nil), // 1: radio.v1.GetCurrentUserResponse - (*emptypb.Empty)(nil), // 2: google.protobuf.Empty + (*ListUsersResponse)(nil), // 2: radio.v1.ListUsersResponse + (*emptypb.Empty)(nil), // 3: google.protobuf.Empty } var file_radio_v1_user_proto_depIdxs = []int32{ 0, // 0: radio.v1.GetCurrentUserResponse.user:type_name -> radio.v1.User - 2, // 1: radio.v1.UserService.GetCurrentUser:input_type -> google.protobuf.Empty - 1, // 2: radio.v1.UserService.GetCurrentUser:output_type -> radio.v1.GetCurrentUserResponse - 2, // [2:3] is the sub-list for method output_type - 1, // [1:2] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 0, // 1: radio.v1.ListUsersResponse.users:type_name -> radio.v1.User + 3, // 2: radio.v1.UserService.GetCurrentUser:input_type -> google.protobuf.Empty + 3, // 3: radio.v1.UserService.ListUsers:input_type -> google.protobuf.Empty + 1, // 4: radio.v1.UserService.GetCurrentUser:output_type -> radio.v1.GetCurrentUserResponse + 2, // 5: radio.v1.UserService.ListUsers:output_type -> radio.v1.ListUsersResponse + 4, // [4:6] is the sub-list for method output_type + 2, // [2:4] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_radio_v1_user_proto_init() } @@ -182,7 +233,7 @@ func file_radio_v1_user_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_radio_v1_user_proto_rawDesc), len(file_radio_v1_user_proto_rawDesc)), NumEnums: 0, - NumMessages: 2, + NumMessages: 3, NumExtensions: 0, NumServices: 1, }, diff --git a/backend/internal/channel/handler.go b/backend/internal/channel/handler.go index 2f82604..9f9fa3d 100644 --- a/backend/internal/channel/handler.go +++ b/backend/internal/channel/handler.go @@ -3,12 +3,15 @@ package channel import ( "context" "errors" + "fmt" "log/slog" + "slices" "connectrpc.com/connect" radiov1 "echsen.club/radio/gen/radio/v1" s "echsen.club/radio/internal/store" "echsen.club/radio/internal/user" + "github.com/google/uuid" "google.golang.org/protobuf/types/known/emptypb" ) @@ -26,10 +29,6 @@ func (handler *ChannelHandler) ChannelList( ctx context.Context, empty *emptypb.Empty, ) (*radiov1.ChannelListResponse, error) { - _, err := user.RequireUser(ctx) - if err != nil { - return nil, err - } channels, err := handler.store.ChannelList(ctx) if err != nil { @@ -44,3 +43,33 @@ func (handler *ChannelHandler) ChannelList( return &radiov1.ChannelListResponse{Channels: pbChannels}, nil } + +func (handler *ChannelHandler) ChannelAddEditor(ctx context.Context, req *radiov1.ChannelAddEditorRequest) (*emptypb.Empty, error) { + user, err := user.RequireUser(ctx) + if err != nil { + return nil, err + } + + channelId, err := uuid.Parse(req.ChannelId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("uuid not valid")) + } + + channel, err := handler.store.GetChannel(ctx, channelId) + if err != nil { + slog.Info("channel not found") + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("channel not found")) + } + + if channel.OwnerID != user.ID || !slices.Contains(channel.Editors, user.ID) { + return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("unauthorized")) + } + + err = handler.store.AddEditorToChannel(ctx, channelId, user.ID) + if err != nil { + slog.Info("couldnt add editor to channel") + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("couldnt add editor to channel")) + } + + return nil, nil +} diff --git a/backend/internal/channel/song.go b/backend/internal/channel/song.go index 9d344a3..56f1508 100644 --- a/backend/internal/channel/song.go +++ b/backend/internal/channel/song.go @@ -14,11 +14,6 @@ import ( ) func (handler *ChannelHandler) ChannelGetSongs(ctx context.Context, req *radiov1.ChannelGetSongsRequest) (*radiov1.ChannelGetSongsResponse, error) { - _, err := user.RequireUser(ctx) - if err != nil { - return nil, err - } - channel_id, err := uuid.Parse(req.ChannelId) if err != nil { return nil, err diff --git a/backend/internal/models/channel.go b/backend/internal/models/channel.go index 28a3afb..5ab7ada 100644 --- a/backend/internal/models/channel.go +++ b/backend/internal/models/channel.go @@ -11,6 +11,7 @@ import ( type Channel struct { ID uuid.UUID `db:"id"` OwnerID uuid.UUID `db:"owner_id"` + Editors []uuid.UUID Frequency string `db:"frequency"` Description string `db:"description"` CreatedAt time.Time `db:"created_at"` @@ -18,6 +19,10 @@ type Channel struct { } func (c Channel) ToProto() *radiov1.Channel { + editorString := make([]string, len(c.Editors)) + for i, id := range c.Editors { + editorString[i] = id.String() + } return &radiov1.Channel{ Id: c.ID.String(), OwnerId: c.OwnerID.String(), @@ -25,5 +30,6 @@ func (c Channel) ToProto() *radiov1.Channel { Description: c.Description, CreatedAt: timestamppb.New(c.CreatedAt), ModifiedAt: timestamppb.New(c.ModifiedAt), + EditorIds: editorString, } } diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 1da0121..b3c3191 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -13,6 +13,7 @@ type User struct { Email string `db:"email"` DisplayName string `db:"display_name"` CreatedAt time.Time `db:"created_at"` + LastLogin time.Time `db:"last_login"` } func (u User) ToProto() *radiov1.User { diff --git a/backend/internal/store/channel.go b/backend/internal/store/channel.go index 8002500..34b53cc 100644 --- a/backend/internal/store/channel.go +++ b/backend/internal/store/channel.go @@ -58,7 +58,7 @@ func (s *Store) GetChannel(ctx context.Context, id uuid.UUID) (*m.Channel, error var c m.Channel query := `SELECT * FROM channels WHERE id = $1 LIMIT 1` - err := s.db.GetContext(ctx, &c, query, id) + err := s.db.SelectContext(ctx, &c, query, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -68,6 +68,17 @@ func (s *Store) GetChannel(ctx context.Context, id uuid.UUID) (*m.Channel, error return nil, err } + queryEditors := ` + SELECT u.id + FROM users u + JOIN channel_editors ce ON u.id = ce.user_id + WHERE ce.channel_id = $1 + ` + err = s.db.GetContext(ctx, &c.Editors, queryEditors, id) + if err != nil { + slog.Error("couldnt get editors for channel", "error", err) + } + return &c, nil } @@ -123,6 +134,20 @@ func (store *Store) RemoveSongFromChannel(ctx context.Context, song_id uuid.UUID return nil } +func (store *Store) AddEditorToChannel(ctx context.Context, channel_id uuid.UUID, user_id uuid.UUID) error { + query := ` + INSERT INTO channel_editors (channel_id, user_id) + VALUES ($1, $2) + ` + + _, err := store.db.ExecContext(ctx, query, channel_id, user_id) + if err != nil { + slog.Error("couldnt add editor to channel", "error", err) + return fmt.Errorf("couldnt get editors from channel") + } + return nil +} + // func (s *Store) ChannelAssignFrequency(id uuid.UUID, frequency string) error { // query := ` // ` diff --git a/backend/internal/store/user.go b/backend/internal/store/user.go index 33ae650..828aab9 100644 --- a/backend/internal/store/user.go +++ b/backend/internal/store/user.go @@ -3,7 +3,9 @@ package store import ( "context" "fmt" + "log/slog" + "echsen.club/radio/internal/models" m "echsen.club/radio/internal/models" "github.com/google/uuid" ) @@ -41,3 +43,15 @@ func (s *Store) Upsert(ctx context.Context, subjectID, email, name string) (uuid return id, nil } + +func (s *Store) ListUsers(ctx context.Context) ([]models.User, error) { + var users []models.User + + err := s.db.SelectContext(ctx, &users, `SELECT * from users`) + if err != nil { + slog.Error("couldnt list users", "error", err) + return nil, fmt.Errorf("couldnt list users") + } + + return users, nil +} diff --git a/backend/internal/user/handler.go b/backend/internal/user/handler.go index 20f4104..58d1546 100644 --- a/backend/internal/user/handler.go +++ b/backend/internal/user/handler.go @@ -2,7 +2,9 @@ package user import ( "context" + "errors" + "connectrpc.com/connect" radiov1 "echsen.club/radio/gen/radio/v1" "echsen.club/radio/internal/config" s "echsen.club/radio/internal/store" @@ -25,3 +27,17 @@ func (handler *UserHandler) GetCurrentUser(ctx context.Context, _ *emptypb.Empty } return &radiov1.GetCurrentUserResponse{User: user.ToProto()}, nil } + +func (handler *UserHandler) ListUsers(ctx context.Context, _ *emptypb.Empty) (*radiov1.ListUsersResponse, error) { + users, err := handler.store.ListUsers(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("couldnt list users")) + } + + usersPb := make([]*radiov1.User, len(users)) + for i, s := range users { + usersPb[i] = s.ToProto() + } + + return &radiov1.ListUsersResponse{Users: usersPb}, nil +} diff --git a/backend/migrations/20260118105330_add_channel_editors.sql b/backend/migrations/20260118105330_add_channel_editors.sql new file mode 100644 index 0000000..a37bbf0 --- /dev/null +++ b/backend/migrations/20260118105330_add_channel_editors.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE channel_editors ( + channel_id UUID REFERENCES channels(id) on DELETE CASCADE, + user_id UUID REFERENCES users(id) on DELETE CASCADE, + PRIMARY KEY (channel_id, user_id), + added_at TIMESTAMPTZ DEFAULT NOW() +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE channel_editors; +-- +goose StatementEnd diff --git a/backend/radio.toml b/backend/radio.toml index 54c295a..3fc97fd 100644 --- a/backend/radio.toml +++ b/backend/radio.toml @@ -3,8 +3,8 @@ PostgresUrl = "postgres://user:password@localhost:5432/radio?sslmode=disable" SongDirectory = "/home/lk/repos/radio-web/songs" [auth] -Issuer = "http://localhost:8081/default" -ClientId = "test" +Issuer = "http://localhost:5556" +ClientId = "radio" ClientSecret = "test" RedirectUrl = "http://localhost:8080/auth/callback" SessionKey = "askdjaksdhkajsdakjhd" diff --git a/dex-config.yaml b/dex-config.yaml new file mode 100644 index 0000000..d2e065c --- /dev/null +++ b/dex-config.yaml @@ -0,0 +1,26 @@ +issuer: http://localhost:5556 +storage: + type: memory # For local dev, no DB needed + +enablePasswordDB: true + +web: + http: 0.0.0.0:5556 + +staticClients: + - id: radio + redirectURIs: + - 'http://localhost:8080/auth/callback' + name: 'Radio App' + secret: test + +# PRE-CONFIGURED USERS +staticPasswords: + - email: "alice@echsen.club" + hash: "$2b$10$mpQ4dsvycHwGCfj9TPKrjuxxLF7X06rj1rYsiguDC3gcRrHhO5Lni" + username: "Alice" + userID: "00000000-0000-0000-0000-000000000001" + - email: "bob@echsen.club" + hash: "$2b$10$mpQ4dsvycHwGCfj9TPKrjuxxLF7X06rj1rYsiguDC3gcRrHhO5Lni" + username: "Bob" + userID: "00000000-0000-0000-0000-000000000002" diff --git a/docker-compose.yaml b/docker-compose.yaml index 9f0316a..fe9a4d5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,27 +14,11 @@ services: timeout: 5s retries: 5 - mock-auth: - image: ghcr.io/navikt/mock-oauth2-server:2.1.0 - container_name: radio_auth_mock + dex: + image: ghcr.io/dexidp/dex:v2.37.0 + container_name: radio_dex + volumes: + - ./dex-config.yaml:/etc/dex/config.yaml ports: - - "8081:8080" - environment: - # Pre-configure a default tenant - SERVER_PORT: 8080 - JSON_CONFIG: | - { - "interactiveLogin": true, - "httpServer": "NettyWrapper" - } - - pgadmin: - image: dpage/pgadmin4 - container_name: radio_pgadmin - environment: - PGADMIN_DEFAULT_EMAIL: admin@echsen.club - PGADMIN_DEFAULT_PASSWORD: admin - ports: - - "5050:80" - depends_on: - - postgres + - "5556:5556" + command: ["dex", "serve", "/etc/dex/config.yaml"] diff --git a/frontend/src/gen/radio/v1/channel_pb.ts b/frontend/src/gen/radio/v1/channel_pb.ts index f8d3f21..23575e7 100644 --- a/frontend/src/gen/radio/v1/channel_pb.ts +++ b/frontend/src/gen/radio/v1/channel_pb.ts @@ -15,7 +15,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file radio/v1/channel.proto. */ export const file_radio_v1_channel: GenFile = /*@__PURE__*/ - fileDesc("ChZyYWRpby92MS9jaGFubmVsLnByb3RvEghyYWRpby52MSLEAQoHQ2hhbm5lbBIUCgJpZBgBIAEoCUIIukgFcgOwAQESGgoIb3duZXJfaWQYAiABKAlCCLpIBXIDsAEBEhEKCWZyZXF1ZW5jeRgDIAEoCRITCgtkZXNjcmlwdGlvbhgEIAEoCRIuCgpjcmVhdGVkX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgttb2RpZmllZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiPgoUQ3JlYXRlQ2hhbm5lbFJlcXVlc3QSEQoJZnJlcXVlbmN5GAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJIjsKFUNyZWF0ZUNoYW5uZWxSZXNwb25zZRIiCgdjaGFubmVsGAEgASgLMhEucmFkaW8udjEuQ2hhbm5lbCIsChREZWxldGVDaGFubmVsUmVxdWVzdBIUCgJpZBgBIAEoCUIIukgFcgOwAQEiOgoTQ2hhbm5lbExpc3RSZXNwb25zZRIjCghjaGFubmVscxgBIAMoCzIRLnJhZGlvLnYxLkNoYW5uZWwiUAoVQ2hhbm5lbEFkZFNvbmdSZXF1ZXN0EhkKB3NvbmdfaWQYASABKAlCCLpIBXIDsAEBEhwKCmNoYW5uZWxfaWQYAiABKAlCCLpIBXIDsAEBIlMKGENoYW5uZWxSZW1vdmVTb25nUmVxdWVzdBIZCgdzb25nX2lkGAEgASgJQgi6SAVyA7ABARIcCgpjaGFubmVsX2lkGAIgASgJQgi6SAVyA7ABASI2ChZDaGFubmVsR2V0U29uZ3NSZXF1ZXN0EhwKCmNoYW5uZWxfaWQYASABKAlCCLpIBXIDsAEBIjgKF0NoYW5uZWxHZXRTb25nc1Jlc3BvbnNlEh0KBXNvbmdzGAEgAygLMg4ucmFkaW8udjEuU29uZzLlAwoOQ2hhbm5lbFNlcnZpY2USUAoNQ3JlYXRlQ2hhbm5lbBIeLnJhZGlvLnYxLkNyZWF0ZUNoYW5uZWxSZXF1ZXN0Gh8ucmFkaW8udjEuQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEkcKDURlbGV0ZUNoYW5uZWwSHi5yYWRpby52MS5EZWxldGVDaGFubmVsUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRJECgtDaGFubmVsTGlzdBIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRodLnJhZGlvLnYxLkNoYW5uZWxMaXN0UmVzcG9uc2USSQoOQ2hhbm5lbEFkZFNvbmcSHy5yYWRpby52MS5DaGFubmVsQWRkU29uZ1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkSTwoRQ2hhbm5lbFJlbW92ZVNvbmcSIi5yYWRpby52MS5DaGFubmVsUmVtb3ZlU29uZ1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkSVgoPQ2hhbm5lbEdldFNvbmdzEiAucmFkaW8udjEuQ2hhbm5lbEdldFNvbmdzUmVxdWVzdBohLnJhZGlvLnYxLkNoYW5uZWxHZXRTb25nc1Jlc3BvbnNlQoUBCgxjb20ucmFkaW8udjFCDENoYW5uZWxQcm90b1ABWiZlY2hzZW4uY2x1Yi9yYWRpby9nZW4vcmFkaW8vdjE7cmFkaW92MaICA1JYWKoCCFJhZGlvLlYxygIIUmFkaW9cVjHiAhRSYWRpb1xWMVxHUEJNZXRhZGF0YeoCCVJhZGlvOjpWMWIGcHJvdG8z", [file_buf_validate_validate, file_google_protobuf_empty, file_google_protobuf_timestamp, file_radio_v1_song]); + fileDesc("ChZyYWRpby92MS9jaGFubmVsLnByb3RvEghyYWRpby52MSLYAQoHQ2hhbm5lbBIUCgJpZBgBIAEoCUIIukgFcgOwAQESGgoIb3duZXJfaWQYAiABKAlCCLpIBXIDsAEBEhEKCWZyZXF1ZW5jeRgDIAEoCRITCgtkZXNjcmlwdGlvbhgEIAEoCRIuCgpjcmVhdGVkX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgttb2RpZmllZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEgoKZWRpdG9yX2lkcxgHIAMoCSI+ChRDcmVhdGVDaGFubmVsUmVxdWVzdBIRCglmcmVxdWVuY3kYASABKAkSEwoLZGVzY3JpcHRpb24YAiABKAkiOwoVQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEiIKB2NoYW5uZWwYASABKAsyES5yYWRpby52MS5DaGFubmVsIiwKFERlbGV0ZUNoYW5uZWxSZXF1ZXN0EhQKAmlkGAEgASgJQgi6SAVyA7ABASI6ChNDaGFubmVsTGlzdFJlc3BvbnNlEiMKCGNoYW5uZWxzGAEgAygLMhEucmFkaW8udjEuQ2hhbm5lbCJQChVDaGFubmVsQWRkU29uZ1JlcXVlc3QSGQoHc29uZ19pZBgBIAEoCUIIukgFcgOwAQESHAoKY2hhbm5lbF9pZBgCIAEoCUIIukgFcgOwAQEiUwoYQ2hhbm5lbFJlbW92ZVNvbmdSZXF1ZXN0EhkKB3NvbmdfaWQYASABKAlCCLpIBXIDsAEBEhwKCmNoYW5uZWxfaWQYAiABKAlCCLpIBXIDsAEBIjYKFkNoYW5uZWxHZXRTb25nc1JlcXVlc3QSHAoKY2hhbm5lbF9pZBgBIAEoCUIIukgFcgOwAQEiOAoXQ2hhbm5lbEdldFNvbmdzUmVzcG9uc2USHQoFc29uZ3MYASADKAsyDi5yYWRpby52MS5Tb25nIlIKF0NoYW5uZWxBZGRFZGl0b3JSZXF1ZXN0EhkKB3VzZXJfaWQYASABKAlCCLpIBXIDsAEBEhwKCmNoYW5uZWxfaWQYAiABKAlCCLpIBXIDsAEBMrQECg5DaGFubmVsU2VydmljZRJQCg1DcmVhdGVDaGFubmVsEh4ucmFkaW8udjEuQ3JlYXRlQ2hhbm5lbFJlcXVlc3QaHy5yYWRpby52MS5DcmVhdGVDaGFubmVsUmVzcG9uc2USRwoNRGVsZXRlQ2hhbm5lbBIeLnJhZGlvLnYxLkRlbGV0ZUNoYW5uZWxSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5EkQKC0NoYW5uZWxMaXN0EhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5Gh0ucmFkaW8udjEuQ2hhbm5lbExpc3RSZXNwb25zZRJJCg5DaGFubmVsQWRkU29uZxIfLnJhZGlvLnYxLkNoYW5uZWxBZGRTb25nUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRJPChFDaGFubmVsUmVtb3ZlU29uZxIiLnJhZGlvLnYxLkNoYW5uZWxSZW1vdmVTb25nUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRJWCg9DaGFubmVsR2V0U29uZ3MSIC5yYWRpby52MS5DaGFubmVsR2V0U29uZ3NSZXF1ZXN0GiEucmFkaW8udjEuQ2hhbm5lbEdldFNvbmdzUmVzcG9uc2USTQoQQ2hhbm5lbEFkZEVkaXRvchIhLnJhZGlvLnYxLkNoYW5uZWxBZGRFZGl0b3JSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5QoUBCgxjb20ucmFkaW8udjFCDENoYW5uZWxQcm90b1ABWiZlY2hzZW4uY2x1Yi9yYWRpby9nZW4vcmFkaW8vdjE7cmFkaW92MaICA1JYWKoCCFJhZGlvLlYxygIIUmFkaW9cVjHiAhRSYWRpb1xWMVxHUEJNZXRhZGF0YeoCCVJhZGlvOjpWMWIGcHJvdG8z", [file_buf_validate_validate, file_google_protobuf_empty, file_google_protobuf_timestamp, file_radio_v1_song]); /** * @generated from message radio.v1.Channel @@ -50,6 +50,11 @@ export type Channel = Message<"radio.v1.Channel"> & { * @generated from field: google.protobuf.Timestamp modified_at = 6; */ modifiedAt?: Timestamp; + + /** + * @generated from field: repeated string editor_ids = 7; + */ + editorIds: string[]; }; /** @@ -210,6 +215,28 @@ export type ChannelGetSongsResponse = Message<"radio.v1.ChannelGetSongsResponse" export const ChannelGetSongsResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_radio_v1_channel, 8); +/** + * @generated from message radio.v1.ChannelAddEditorRequest + */ +export type ChannelAddEditorRequest = Message<"radio.v1.ChannelAddEditorRequest"> & { + /** + * @generated from field: string user_id = 1; + */ + userId: string; + + /** + * @generated from field: string channel_id = 2; + */ + channelId: string; +}; + +/** + * Describes the message radio.v1.ChannelAddEditorRequest. + * Use `create(ChannelAddEditorRequestSchema)` to create a new message. + */ +export const ChannelAddEditorRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_radio_v1_channel, 9); + /** * @generated from service radio.v1.ChannelService */ @@ -262,6 +289,14 @@ export const ChannelService: GenService<{ input: typeof ChannelGetSongsRequestSchema; output: typeof ChannelGetSongsResponseSchema; }, + /** + * @generated from rpc radio.v1.ChannelService.ChannelAddEditor + */ + channelAddEditor: { + methodKind: "unary"; + input: typeof ChannelAddEditorRequestSchema; + output: typeof EmptySchema; + }, }> = /*@__PURE__*/ serviceDesc(file_radio_v1_channel, 0); diff --git a/frontend/src/gen/radio/v1/user_pb.ts b/frontend/src/gen/radio/v1/user_pb.ts index 2b0b151..cd22656 100644 --- a/frontend/src/gen/radio/v1/user_pb.ts +++ b/frontend/src/gen/radio/v1/user_pb.ts @@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file radio/v1/user.proto. */ export const file_radio_v1_user: GenFile = /*@__PURE__*/ - fileDesc("ChNyYWRpby92MS91c2VyLnByb3RvEghyYWRpby52MSI3CgRVc2VyEgoKAmlkGAEgASgJEg0KBWVtYWlsGAIgASgJEhQKDGRpc3BsYXlfbmFtZRgDIAEoCSI2ChZHZXRDdXJyZW50VXNlclJlc3BvbnNlEhwKBHVzZXIYASABKAsyDi5yYWRpby52MS5Vc2VyMlkKC1VzZXJTZXJ2aWNlEkoKDkdldEN1cnJlbnRVc2VyEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GiAucmFkaW8udjEuR2V0Q3VycmVudFVzZXJSZXNwb25zZUKCAQoMY29tLnJhZGlvLnYxQglVc2VyUHJvdG9QAVomZWNoc2VuLmNsdWIvcmFkaW8vZ2VuL3JhZGlvL3YxO3JhZGlvdjGiAgNSWFiqAghSYWRpby5WMcoCCFJhZGlvXFYx4gIUUmFkaW9cVjFcR1BCTWV0YWRhdGHqAglSYWRpbzo6VjFiBnByb3RvMw", [file_buf_validate_validate, file_google_protobuf_empty]); + fileDesc("ChNyYWRpby92MS91c2VyLnByb3RvEghyYWRpby52MSI3CgRVc2VyEgoKAmlkGAEgASgJEg0KBWVtYWlsGAIgASgJEhQKDGRpc3BsYXlfbmFtZRgDIAEoCSI2ChZHZXRDdXJyZW50VXNlclJlc3BvbnNlEhwKBHVzZXIYASABKAsyDi5yYWRpby52MS5Vc2VyIjIKEUxpc3RVc2Vyc1Jlc3BvbnNlEh0KBXVzZXJzGAEgAygLMg4ucmFkaW8udjEuVXNlcjKbAQoLVXNlclNlcnZpY2USSgoOR2V0Q3VycmVudFVzZXISFi5nb29nbGUucHJvdG9idWYuRW1wdHkaIC5yYWRpby52MS5HZXRDdXJyZW50VXNlclJlc3BvbnNlEkAKCUxpc3RVc2VycxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRobLnJhZGlvLnYxLkxpc3RVc2Vyc1Jlc3BvbnNlQoIBCgxjb20ucmFkaW8udjFCCVVzZXJQcm90b1ABWiZlY2hzZW4uY2x1Yi9yYWRpby9nZW4vcmFkaW8vdjE7cmFkaW92MaICA1JYWKoCCFJhZGlvLlYxygIIUmFkaW9cVjHiAhRSYWRpb1xWMVxHUEJNZXRhZGF0YeoCCVJhZGlvOjpWMWIGcHJvdG8z", [file_buf_validate_validate, file_google_protobuf_empty]); /** * @generated from message radio.v1.User @@ -59,6 +59,23 @@ export type GetCurrentUserResponse = Message<"radio.v1.GetCurrentUserResponse"> export const GetCurrentUserResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_radio_v1_user, 1); +/** + * @generated from message radio.v1.ListUsersResponse + */ +export type ListUsersResponse = Message<"radio.v1.ListUsersResponse"> & { + /** + * @generated from field: repeated radio.v1.User users = 1; + */ + users: User[]; +}; + +/** + * Describes the message radio.v1.ListUsersResponse. + * Use `create(ListUsersResponseSchema)` to create a new message. + */ +export const ListUsersResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_radio_v1_user, 2); + /** * @generated from service radio.v1.UserService */ @@ -71,6 +88,14 @@ export const UserService: GenService<{ input: typeof EmptySchema; output: typeof GetCurrentUserResponseSchema; }, + /** + * @generated from rpc radio.v1.UserService.ListUsers + */ + listUsers: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof ListUsersResponseSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_radio_v1_user, 0); diff --git a/frontend/src/lib/components/AddSongSection.svelte b/frontend/src/lib/components/AddSongSection.svelte index de60884..c5b166f 100644 --- a/frontend/src/lib/components/AddSongSection.svelte +++ b/frontend/src/lib/components/AddSongSection.svelte @@ -75,7 +75,7 @@ {#if view === 'upload'}
- MODE: LOCAL_UPLOAD + MODE: SERVER_UPLOAD
diff --git a/frontend/src/lib/components/EditChannelForm.svelte b/frontend/src/lib/components/EditChannelForm.svelte new file mode 100644 index 0000000..8b438dd --- /dev/null +++ b/frontend/src/lib/components/EditChannelForm.svelte @@ -0,0 +1,380 @@ + + +
+
+ CHANNEL_CONFIGURATION_UTILITY v1.1 [ID:{channelId.slice(0,8)}...] + +
+ +
+
--- GENERAL SETTINGS ---
+
+ + +
+
+ + +
+ +
+ +
+
+ --- EDITOR MANAGEMENT --- + +
+ +
+
CURRENT EDITORS
+
+ {#if currentEditors.length > 0} + {#each currentEditors as user} +
+ + +
+ {/each} + {:else if userLoading} +
LOADING_USER_DATA...
+ {:else} +
NO ADDITIONAL EDITORS AUTHORIZED
+ {/if} +
+
+ +
+
AUTHORIZE NEW EDITOR
+
+ {#if userLoading} +
LOADING_USERS...
+ {:else if availableUsers.length === 0} +
NO OTHER USERS AVAILABLE
+ {:else} + {#each availableUsers as user} +
+ + +
+ {/each} + {/if} +
+
+
+ + {#if error} +
! ERROR: {error}
+ {/if} +
+ + diff --git a/frontend/src/routes/[channelId]/+page.svelte b/frontend/src/routes/[channelId]/+page.svelte index f6218b9..60be8ea 100644 --- a/frontend/src/routes/[channelId]/+page.svelte +++ b/frontend/src/routes/[channelId]/+page.svelte @@ -3,6 +3,7 @@ import { userState } from '$lib/state/user.svelte'; import TerminalList from '$lib/components/TerminalList.svelte'; import AddSongSection from '$lib/components/AddSongSection.svelte'; + import EditChannelForm from '$lib/components/EditChannelForm.svelte'; import TerminalConfirm from '$lib/components/TerminalConfirm.svelte'; import { channelClient } from '$lib/rpc'; import { goto, invalidateAll } from '$app/navigation'; @@ -30,6 +31,7 @@ // Confirm Dialog States let showDeleteChannelConfirm = $state(false); let songToDeleteId = $state(null); + let isEditing = $state(false); function handleSelect(id: string) { const song = items.find((s: { id: string }) => s.id === id); @@ -82,37 +84,63 @@ - {#if isOwner} - - {/if} +
+ {#if isOwner} + + + {/if} +
-

PLAYLIST

+ +
+

{data.channel?.frequency || 'UNKNOWN'}

+
+ OWNER: + + {data.channel?.ownerId === userState.id ? (userState.displayName || userState.email || userState.id) : data.channel?.ownerId} + + + EDITORS: + + {data.channel?.editorIds && data.channel.editorIds.length > 0 ? data.channel.editorIds.join(', ') : '[-]'} + +
+
+
----------------
- {#if isOwner} - + {#if isEditing && isOwner} + isEditing = false} /> + {:else} + {#if isOwner} + + {/if} {/if} -{#if data.error} -
- ERROR: {data.error} -
- Check backend logs or connection. -
-{:else if items.length === 0} -
- -- NO SONGS FOUND -- -
-{:else} - +{#if !isEditing || !isOwner} + {#if data.error} +
+ ERROR: {data.error} +
+ Check backend logs or connection. +
+ {:else if items.length === 0} +
+ -- NO SONGS FOUND -- +
+ {:else} + + {/if} {/if}