diff --git a/backend/Dockerfile.server b/backend/Dockerfile.server index 4d6794bee..2d8194e39 100644 --- a/backend/Dockerfile.server +++ b/backend/Dockerfile.server @@ -18,4 +18,4 @@ COPY --from=builder /app/bin/sac /sac EXPOSE 8080 -ENTRYPOINT [ "/sac" ] \ No newline at end of file +ENTRYPOINT [ "/sac" ] diff --git a/backend/config/app.go b/backend/config/app.go index 7b6f45acd..f1354ddb4 100644 --- a/backend/config/app.go +++ b/backend/config/app.go @@ -1,19 +1,7 @@ package config -import "fmt" - type ApplicationSettings struct { - Port uint16 `env:"PORT"` - Host string `env:"HOST"` - BaseUrl string `env:"BASE_URL"` -} - -func (s *ApplicationSettings) ApplicationURL() string { - var host string - if s.Host == "127.0.0.1" { - host = "localhost" - } else { - host = s.Host - } - return fmt.Sprintf("http://%s:%d", host, s.Port) + Port uint16 `env:"PORT"` + Host string `env:"HOST"` + PublicURL string `env:"PUBLIC_URL"` } diff --git a/backend/config/config.go b/backend/config/config.go index 010b5a555..152c8cbb2 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -8,8 +8,10 @@ import ( ) func GetConfiguration(path string) (*Settings, error) { - if err := godotenv.Load(path); err != nil { - return nil, fmt.Errorf("failed to load environment variables: %s", err.Error()) + if path != "" { + if err := godotenv.Load(path); err != nil { + return nil, fmt.Errorf("failed to load environment variables: %s", err.Error()) + } } intSettings, err := env.ParseAs[intermediateSettings]() diff --git a/backend/config/oauth_microsoft.go b/backend/config/oauth_microsoft.go index f1c140123..009214a47 100644 --- a/backend/config/oauth_microsoft.go +++ b/backend/config/oauth_microsoft.go @@ -6,23 +6,51 @@ const ( tenantID string = "a8eec281-aaa3-4dae-ac9b-9a398b9215e7" ) -type MicrosoftOAuthSettings struct { +type MicrosoftWebOAuthSettings struct { Key *m.Secret[string] Secret *m.Secret[string] Tenant string } -type intermediateMicrosoftOAuthSetting struct { +type MicrosoftMobileOAuthSettings struct { + Key *m.Secret[string] + Tenant string +} + +type intermediateMicrosoftWebOAuthSettings struct { + Key string `env:"KEY"` + Secret string `env:"SECRET"` +} + +func (i *intermediateMicrosoftWebOAuthSettings) into() (*MicrosoftWebOAuthSettings, error) { + secretKey, err := m.NewSecret(i.Key) + if err != nil { + return nil, err + } + + secretSecret, err := m.NewSecret(i.Secret) + if err != nil { + return nil, err + } + + return &MicrosoftWebOAuthSettings{ + Key: secretKey, + Secret: secretSecret, + Tenant: tenantID, + }, nil +} + +type intermediateMicrosoftMobileOAuthSettings struct { Key string `env:"KEY"` } -func (i *intermediateMicrosoftOAuthSetting) into() (*MicrosoftOAuthSettings, error) { +func (i *intermediateMicrosoftMobileOAuthSettings) into() (*MicrosoftMobileOAuthSettings, error) { secretKey, err := m.NewSecret(i.Key) if err != nil { return nil, err } - return &MicrosoftOAuthSettings{ + return &MicrosoftMobileOAuthSettings{ Key: secretKey, Tenant: tenantID, }, nil diff --git a/backend/config/settings.go b/backend/config/settings.go index 1b3b7ffe5..12ea92745 100644 --- a/backend/config/settings.go +++ b/backend/config/settings.go @@ -12,11 +12,12 @@ type Settings struct { } type Integrations struct { - Google GoogleOAuthSettings - Microsft MicrosoftOAuthSettings - AWS AWSSettings - Resend ResendSettings - Search SearchSettings + Google GoogleOAuthSettings + MicrosoftWeb MicrosoftWebOAuthSettings + MicrosoftMobile MicrosoftMobileOAuthSettings + AWS AWSSettings + Resend ResendSettings + Search SearchSettings } type intermediateSettings struct { @@ -86,7 +87,12 @@ func (i *intermediateSettings) into() (*Settings, error) { return nil, err } - microsoft, err := i.Microsft.into() + microsoftWeb, err := i.MicrosoftWeb.into() + if err != nil { + return nil, err + } + + microsoftMobile, err := i.MicrosoftMobile.into() if err != nil { return nil, err } @@ -105,11 +111,12 @@ func (i *intermediateSettings) into() (*Settings, error) { SuperUser: *superUser, Calendar: *calendar, Integrations: Integrations{ - Google: *google, - Microsft: *microsoft, - AWS: *aws, - Resend: *resend, - Search: *search, + Google: *google, + MicrosoftWeb: *microsoftWeb, + MicrosoftMobile: *microsoftMobile, + AWS: *aws, + Resend: *resend, + Search: i.Search, }, }, nil } diff --git a/backend/entities/auth/base/handlers.go b/backend/entities/auth/base/handlers.go index b5e39179a..ce657fdf9 100644 --- a/backend/entities/auth/base/handlers.go +++ b/backend/entities/auth/base/handlers.go @@ -7,6 +7,7 @@ import ( "github.com/GenerateNU/sac/backend/integrations/oauth/soth" "github.com/GenerateNU/sac/backend/integrations/oauth/soth/sothic" + "github.com/GenerateNU/sac/backend/utilities" "github.com/gofiber/fiber/v2" "gorm.io/gorm" @@ -20,12 +21,20 @@ type Service interface { } type Handler struct { - db *gorm.DB - authProvider soth.Provider + db *gorm.DB + webAuthProvider soth.Provider + mobileAuthProvider soth.Provider } func (h *Handler) Login(c *fiber.Ctx) error { - sothic.SetProvider(c, h.authProvider.Name()) + switch utilities.GetPlatform(c) { + case utilities.PlatformWeb: + sothic.SetProvider(c, h.webAuthProvider.Name()) + case utilities.PlatformMobile: + sothic.SetProvider(c, h.mobileAuthProvider.Name()) + sothic.SetProvider(c, h.mobileAuthProvider.Name()) + } + if gfUser, err := sothic.CompleteUserAuth(c); err == nil { user, err := FindOrCreateUser(context.TODO(), h.db, gfUser) if err != nil { @@ -89,7 +98,7 @@ func (h *Handler) ProviderCallback(c *fiber.Ctx) error { return err } - return c.SendStatus(http.StatusOK) + return c.Status(http.StatusOK).JSON(user) } func (h *Handler) ProviderLogout(c *fiber.Ctx) error { diff --git a/backend/entities/auth/base/routes.go b/backend/entities/auth/base/routes.go index f1df0ec6c..0505c8cb6 100644 --- a/backend/entities/auth/base/routes.go +++ b/backend/entities/auth/base/routes.go @@ -9,29 +9,31 @@ import ( ) type Params struct { - authProvider soth.Provider - providers []soth.Provider - applicationURL string - router fiber.Router - db *gorm.DB + webAuthProvider soth.Provider + mobileAuthProvider soth.Provider + providers []soth.Provider + applicationURL string + router fiber.Router + db *gorm.DB } -func NewParams(authProvider soth.Provider, applicationURL string, router fiber.Router, db *gorm.DB, emailer email.Emailer, validate *validator.Validate, providers ...soth.Provider) Params { +func NewParams(webAuthProvider soth.Provider, mobileAuthProvider soth.Provider, applicationURL string, router fiber.Router, db *gorm.DB, emailer email.Emailer, validate *validator.Validate, providers ...soth.Provider) Params { return Params{ - authProvider: authProvider, - providers: providers, - applicationURL: applicationURL, - router: router, - db: db, + webAuthProvider: webAuthProvider, + mobileAuthProvider: mobileAuthProvider, + providers: providers, + applicationURL: applicationURL, + router: router, + db: db, } } func Auth(params Params) { soth.UseProviders( - append(params.providers, params.authProvider)..., + append(params.providers, params.webAuthProvider, params.mobileAuthProvider)..., ) - handler := Handler{db: params.db, authProvider: params.authProvider} + handler := Handler{db: params.db, webAuthProvider: params.webAuthProvider, mobileAuthProvider: params.mobileAuthProvider} params.router.Route("/auth", func(r fiber.Router) { r.Get("/login", handler.Login) diff --git a/backend/entities/auth/base/transactions.go b/backend/entities/auth/base/transactions.go index 1dd839c52..01ecfb5ed 100644 --- a/backend/entities/auth/base/transactions.go +++ b/backend/entities/auth/base/transactions.go @@ -13,9 +13,11 @@ func FindOrCreateUser(ctx context.Context, db *gorm.DB, user soth.User) (*models var sacUser models.User if err := db.WithContext(ctx).Where("email = ?", user.Email).First(&sacUser).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - if err := createUser(ctx, db, user.Into()); err != nil { + user, err := createUser(ctx, db, *user.Into()) + if err != nil { return nil, err } + return user, nil } else { return nil, err } @@ -24,7 +26,7 @@ func FindOrCreateUser(ctx context.Context, db *gorm.DB, user soth.User) (*models return &sacUser, nil } -func createUser(ctx context.Context, db *gorm.DB, user *models.User) error { +func createUser(ctx context.Context, db *gorm.DB, user models.User) (*models.User, error) { tx := db.WithContext(ctx).Begin() defer func() { if r := recover(); r != nil { @@ -32,16 +34,16 @@ func createUser(ctx context.Context, db *gorm.DB, user *models.User) error { } }() - if err := tx.Create(user).Error; err != nil { + if err := tx.Create(&user).Error; err != nil { tx.Rollback() - return err + return nil, err } welcomeTask := models.WelcomeTask{Name: user.Name, Email: user.Email} if err := tx.Create(&welcomeTask).Error; err != nil { tx.Rollback() - return err + return nil, err } - return tx.Commit().Error + return &user, tx.Commit().Error } diff --git a/backend/entities/clubs/followers/routes.go b/backend/entities/clubs/followers/routes.go index a89ee1f66..4b4e8fa17 100644 --- a/backend/entities/clubs/followers/routes.go +++ b/backend/entities/clubs/followers/routes.go @@ -1,8 +1,6 @@ package followers import ( - authMiddleware "github.com/GenerateNU/sac/backend/middleware/auth" - "github.com/GenerateNU/sac/backend/types" ) @@ -15,12 +13,12 @@ func ClubFollower(clubParams types.RouteParams) { clubFollowers.Get("/", clubParams.UtilityMiddleware.Paginator, clubFollowerController.GetClubFollowers) clubFollowers.Post( "/:userID", - authMiddleware.AttachExtractor(clubParams.AuthMiddleware.ClubAuthorizeById, authMiddleware.ExtractFromParams("clubID")), + clubParams.AuthMiddleware.UserAuthorizeById, clubFollowerController.CreateClubFollowing, ) clubFollowers.Delete( "/:userID", - authMiddleware.AttachExtractor(clubParams.AuthMiddleware.ClubAuthorizeById, authMiddleware.ExtractFromParams("clubID")), + clubParams.AuthMiddleware.UserAuthorizeById, clubFollowerController.DeleteClubFollowing, ) } diff --git a/backend/entities/events/rsvps/routes.go b/backend/entities/events/rsvps/routes.go index c36d5eca8..4a0d999b6 100644 --- a/backend/entities/events/rsvps/routes.go +++ b/backend/entities/events/rsvps/routes.go @@ -10,7 +10,7 @@ func EventsRSVPs(params types.RouteParams) { controller := NewController(NewHandler(params.ServiceParams)) // api/v1/events/:eventID/rsvps/* - params.Router.Route("/rsvps", func(r fiber.Router) { + params.Router.Route("/:eventID/rsvps", func(r fiber.Router) { r.Get("/", params.UtilityMiddleware.Paginator, controller.GetEventRSVPs) r.Post("/:userID", controller.CreateEventRSVP) r.Delete("/:userID", params.AuthMiddleware.UserAuthorizeById, params.AuthMiddleware.Authorize(permission.DeleteAll), controller.DeleteEventRSVP) diff --git a/backend/integrations/oauth/crypt/crypt.go b/backend/integrations/oauth/crypt/crypt.go index 52352c987..20d515ed5 100644 --- a/backend/integrations/oauth/crypt/crypt.go +++ b/backend/integrations/oauth/crypt/crypt.go @@ -17,7 +17,7 @@ func Encrypt(data string, passphrase string) (string, error) { } plaintext := []byte(data) - if len(plaintext) > 1028 { + if len(plaintext) > 4096 { return "", fmt.Errorf("plaintext too long") } diff --git a/backend/integrations/oauth/soth/goog/session.go b/backend/integrations/oauth/soth/goog/session.go index 7937f0ae8..88ef38cd3 100644 --- a/backend/integrations/oauth/soth/goog/session.go +++ b/backend/integrations/oauth/soth/goog/session.go @@ -1,11 +1,12 @@ package goog import ( - "encoding/json" "errors" "strings" "time" + go_json "github.com/goccy/go-json" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" "github.com/GenerateNU/sac/backend/utilities" ) @@ -48,7 +49,7 @@ func (s *Session) Authorize(provider soth.Provider, params soth.Params) (string, // Marshal the session into a string func (s Session) Marshal() string { - b, _ := json.Marshal(s) + b, _ := go_json.Marshal(s) return string(b) } @@ -59,6 +60,6 @@ func (s Session) String() string { // UnmarshalSession will unmarshal a JSON string into a session. func (p *Provider) UnmarshalSession(data string) (soth.Session, error) { sess := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + err := go_json.NewDecoder(strings.NewReader(data)).Decode(sess) return sess, err } diff --git a/backend/integrations/oauth/soth/msft/msft.go b/backend/integrations/oauth/soth/msft_mobile/msft_mobile.go similarity index 97% rename from backend/integrations/oauth/soth/msft/msft.go rename to backend/integrations/oauth/soth/msft_mobile/msft_mobile.go index 624b1ce3f..93a5ad9e8 100644 --- a/backend/integrations/oauth/soth/msft/msft.go +++ b/backend/integrations/oauth/soth/msft_mobile/msft_mobile.go @@ -1,4 +1,4 @@ -package msft +package msft_mobile import ( "bytes" @@ -29,13 +29,13 @@ const ( var defaultScopes = []string{"openid", "offline_access", "user.read", "calendars.readwrite", "email", "profile"} // New creates a new microsoftonline Provider, and sets up important connection details. -// You should always call `msft.New` to get a new Provider. Never try to create +// You should always call `msft_mobile.New` to get a new Provider. Never try to create // one manually. func New(clientKey *m.Secret[string], callbackURL string, tenant string, scopes ...string) *Provider { p := &Provider{ ClientKey: clientKey, CallbackURL: callbackURL, - ProviderName: "microsoftonline", + ProviderName: "microsoftonlineweb", tenant: tenant, } diff --git a/backend/integrations/oauth/soth/msft_mobile/session.go b/backend/integrations/oauth/soth/msft_mobile/session.go new file mode 100644 index 000000000..7194b2727 --- /dev/null +++ b/backend/integrations/oauth/soth/msft_mobile/session.go @@ -0,0 +1,64 @@ +package msft_mobile + +import ( + "errors" + "strings" + "time" + + go_json "github.com/goccy/go-json" + + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" + "github.com/GenerateNU/sac/backend/utilities" +) + +// Session is the implementation of `soth.Session` for accessing microsoftonline. +// Refresh token not available for microsoft online: session size hit the limit of max cookie size +type Session struct { + AuthURL string + AccessToken string + ExpiresAt time.Time +} + +// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Microsoft provider. +func (s Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(soth.NoAuthUrlErrorMessage) + } + + return s.AuthURL, nil +} + +// Authorize the session with Microsoft and return the access token to be stored for future use. +func (s *Session) Authorize(provider soth.Provider, params soth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(soth.ContextForClient(utilities.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("invalid token received from provider") + } + + s.AccessToken = token.AccessToken + s.ExpiresAt = token.Expiry + + return token.AccessToken, err +} + +// Marshal the session into a string +func (s Session) Marshal() string { + b, _ := go_json.Marshal(s) + return string(b) +} + +func (s Session) String() string { + return s.Marshal() +} + +// UnmarshalSession wil unmarshal a JSON string into a session. +func (p *Provider) UnmarshalSession(data string) (soth.Session, error) { + session := &Session{} + err := go_json.NewDecoder(strings.NewReader(data)).Decode(session) + return session, err +} diff --git a/backend/integrations/oauth/soth/msft_web/msft_web.go b/backend/integrations/oauth/soth/msft_web/msft_web.go new file mode 100644 index 000000000..2188c7a92 --- /dev/null +++ b/backend/integrations/oauth/soth/msft_web/msft_web.go @@ -0,0 +1,190 @@ +package msft_web + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + + go_json "github.com/goccy/go-json" + + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" + "github.com/GenerateNU/sac/backend/utilities" + + "github.com/markbates/going/defaults" + "golang.org/x/oauth2" + + m "github.com/garrettladley/mattress" +) + +const ( + // #nosec G101 + authURLFmt string = "https://login.microsoftonline.com/%s/oauth2/v2.0/authorize" + // #nosec G101 + tokenURLFmt string = "https://login.microsoftonline.com/%s/oauth2/v2.0/token" + endpointProfile string = "https://graph.microsoft.com/v1.0/me" +) + +var defaultScopes = []string{"openid", "offline_access", "user.read", "calendars.readwrite", "email", "profile"} + +// New creates a new microsoftonline Provider, and sets up important connection details. +// You should always call `msft_web.New` to get a new Provider. Never try to create +// one manually. +func New(clientKey *m.Secret[string], secret *m.Secret[string], callbackURL string, tenant string, scopes ...string) *Provider { + p := &Provider{ + ClientKey: clientKey, + Secret: secret, + CallbackURL: callbackURL, + ProviderName: "microsoftonlineweb", + tenant: tenant, + } + + p.config = newConfig(p, scopes) + return p +} + +// Provider is the implementation of `soth.Provider` for accessing microsoftonline. +type Provider struct { + ClientKey *m.Secret[string] + Secret *m.Secret[string] + CallbackURL string + config *oauth2.Config + ProviderName string + tenant string +} + +// Name is the name used to retrieve this Provider later. +func (p *Provider) Name() string { + return p.ProviderName +} + +// SetName is to update the name of the Provider (needed in case of multiple Providers of 1 type) +func (p *Provider) SetName(name string) { + p.ProviderName = name +} + +// Debug is a no-op for the msft package. +func (p *Provider) Debug(debug bool) {} + +// BeginAuth asks MicrosoftOnline for an authentication end-point. +func (p *Provider) BeginAuth(state string) (soth.Session, error) { + return &Session{ + AuthURL: p.config.AuthCodeURL(state), + }, nil +} + +// FetchUser will go to MicrosoftOnline and access basic information about the user. +func (p *Provider) FetchUser(session soth.Session) (soth.User, error) { + msSession := session.(*Session) + user := soth.User{ + AccessToken: msSession.AccessToken, + Provider: p.Name(), + ExpiresAt: msSession.ExpiresAt, + } + + if user.AccessToken == "" { + return user, fmt.Errorf("%s cannot get user information without accessToken", p.ProviderName) + } + + resp, err := utilities.Request(http.MethodGet, endpointProfile, nil, utilities.Authorization(msSession.AccessToken)) + if err != nil { + return user, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.ProviderName, resp.StatusCode) + } + + user.AccessToken = msSession.AccessToken + + err = userFromReader(resp.Body, &user) + return user, err +} + +// RefreshTokenAvailable refresh token is provided by auth Provider or not +// available for microsoft online as session size hit the limit of max cookie size +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +// RefreshToken get new access token based on the refresh token +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + if refreshToken == "" { + return nil, errors.New("no refresh token provided") + } + + token := &oauth2.Token{RefreshToken: refreshToken} + ts := p.config.TokenSource(soth.ContextForClient(utilities.Client()), token) + newToken, err := ts.Token() + if err != nil { + return nil, err + } + return newToken, err +} + +func newConfig(provider *Provider, scopes []string) *oauth2.Config { + var ( + authURL string + tokenURL string + ) + if provider.tenant == "" { + authURL = fmt.Sprintf(authURLFmt, "common") + tokenURL = fmt.Sprintf(tokenURLFmt, "common") + } else { + authURL = fmt.Sprintf(authURLFmt, provider.tenant) + tokenURL = fmt.Sprintf(tokenURLFmt, provider.tenant) + } + + c := &oauth2.Config{ + ClientID: provider.ClientKey.Expose(), + ClientSecret: provider.Secret.Expose(), + RedirectURL: provider.CallbackURL, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + Scopes: []string{}, + } + + c.Scopes = append(c.Scopes, scopes...) + if len(scopes) == 0 { + c.Scopes = append(c.Scopes, defaultScopes...) + } + + return c +} + +func userFromReader(r io.Reader, user *soth.User) error { + buf := &bytes.Buffer{} + tee := io.TeeReader(r, buf) + + u := struct { + ID string `json:"id"` + Name string `json:"displayName"` + Email string `json:"mail"` + FirstName string `json:"givenName"` + LastName string `json:"surname"` + UserPrincipalName string `json:"userPrincipalName"` + }{} + + if err := go_json.NewDecoder(tee).Decode(&u); err != nil { + return err + } + + raw := map[string]interface{}{} + if err := go_json.NewDecoder(buf).Decode(&raw); err != nil { + return err + } + + user.UserID = u.ID + user.Email = defaults.String(u.Email, u.UserPrincipalName) + user.Name = u.Name + user.NickName = u.Name + user.FirstName = u.FirstName + user.LastName = u.LastName + user.RawData = raw + + return nil +} diff --git a/backend/integrations/oauth/soth/msft/session.go b/backend/integrations/oauth/soth/msft_web/session.go similarity index 91% rename from backend/integrations/oauth/soth/msft/session.go rename to backend/integrations/oauth/soth/msft_web/session.go index e271d4c4c..9db7e7079 100644 --- a/backend/integrations/oauth/soth/msft/session.go +++ b/backend/integrations/oauth/soth/msft_web/session.go @@ -1,11 +1,12 @@ -package msft +package msft_web import ( - "encoding/json" "errors" "strings" "time" + go_json "github.com/goccy/go-json" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth" "github.com/GenerateNU/sac/backend/utilities" ) @@ -47,7 +48,7 @@ func (s *Session) Authorize(provider soth.Provider, params soth.Params) (string, // Marshal the session into a string func (s Session) Marshal() string { - b, _ := json.Marshal(s) + b, _ := go_json.Marshal(s) return string(b) } @@ -58,6 +59,6 @@ func (s Session) String() string { // UnmarshalSession wil unmarshal a JSON string into a session. func (p *Provider) UnmarshalSession(data string) (soth.Session, error) { session := &Session{} - err := json.NewDecoder(strings.NewReader(data)).Decode(session) + err := go_json.NewDecoder(strings.NewReader(data)).Decode(session) return session, err } diff --git a/backend/integrations/oauth/soth/sothic/sothic.go b/backend/integrations/oauth/soth/sothic/sothic.go index 9768c99b6..7b16f2e4d 100644 --- a/backend/integrations/oauth/soth/sothic/sothic.go +++ b/backend/integrations/oauth/soth/sothic/sothic.go @@ -210,6 +210,7 @@ func CompleteUserAuth(c *fiber.Ctx) (soth.User, error) { } gu, err := provider.FetchUser(sess) + return gu, err } @@ -227,6 +228,7 @@ func validateState(c *fiber.Ctx, sess soth.Session) error { } originalState := authURL.Query().Get("state") + if originalState != "" && (originalState != c.Query("state")) { return errors.New("state token mismatch") } @@ -351,19 +353,18 @@ func updateSessionValue(session *session.Session, key, value string) error { if _, err := gz.Write([]byte(value)); err != nil { return err } + if err := gz.Flush(); err != nil { return err } + if err := gz.Close(); err != nil { return err } - encrypted, err := encrypter(b.String()) if err != nil { return err } - session.Set(key, encrypted) - return nil } diff --git a/backend/main.go b/backend/main.go index 43f2a0a40..3e90ea614 100644 --- a/backend/main.go +++ b/backend/main.go @@ -9,7 +9,6 @@ import ( "net" "os" "os/signal" - "path/filepath" "syscall" "github.com/GenerateNU/sac/backend/background" @@ -17,13 +16,11 @@ import ( "github.com/GenerateNU/sac/backend/config" "github.com/GenerateNU/sac/backend/database" "github.com/GenerateNU/sac/backend/database/store" - - // TODO: disable for prod with build tag - _ "github.com/GenerateNU/sac/backend/docs" "github.com/GenerateNU/sac/backend/integrations" "github.com/GenerateNU/sac/backend/integrations/email" "github.com/GenerateNU/sac/backend/integrations/file" "github.com/GenerateNU/sac/backend/integrations/oauth/soth/sothic" + "github.com/GenerateNU/sac/backend/search" "github.com/GenerateNU/sac/backend/server" "github.com/GenerateNU/sac/backend/telemetry" "github.com/GenerateNU/sac/backend/utilities" diff --git a/backend/middleware/auth/middleware.go b/backend/middleware/auth/middleware.go index 08f61133e..b14d48452 100644 --- a/backend/middleware/auth/middleware.go +++ b/backend/middleware/auth/middleware.go @@ -1,8 +1,6 @@ package auth import ( - "github.com/GenerateNU/sac/backend/integrations/oauth/soth" - "github.com/GenerateNU/sac/backend/permission" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -19,13 +17,11 @@ type AuthMiddlewareService interface { type AuthMiddlewareHandler struct { db *gorm.DB validate *validator.Validate - provider soth.Provider } -func New(db *gorm.DB, validate *validator.Validate, provider soth.Provider) AuthMiddlewareService { +func New(db *gorm.DB, validate *validator.Validate) AuthMiddlewareService { return &AuthMiddlewareHandler{ db: db, validate: validate, - provider: provider, } } diff --git a/backend/middleware/auth/user.go b/backend/middleware/auth/user.go index fd5ee7277..e4d569bf8 100644 --- a/backend/middleware/auth/user.go +++ b/backend/middleware/auth/user.go @@ -17,10 +17,10 @@ func (m *AuthMiddlewareHandler) UserAuthorizeById(c *fiber.Ctx) error { return c.SendStatus(http.StatusUnauthorized) } - user := models.UnmarshalUser(strUser) + user := *models.UnmarshalUser(strUser) if user.Role == models.Super { - locals.SetUser(c, user) + locals.SetUser(c, &user) return c.Next() } @@ -29,8 +29,8 @@ func (m *AuthMiddlewareHandler) UserAuthorizeById(c *fiber.Ctx) error { return err } - if idAsUUID == &user.ID { - locals.SetUser(c, user) + if *idAsUUID == user.ID { + locals.SetUser(c, &user) return c.Next() } diff --git a/backend/redis_entrypoint.sh b/backend/redis_entrypoint.sh index ffa4913bf..37ba71efd 100644 --- a/backend/redis_entrypoint.sh +++ b/backend/redis_entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # set up redis configuration directory mkdir -p /usr/local/etc/redis @@ -12,7 +12,7 @@ if [ -n ${REDIS_USERNAME} ] && [ -n ${REDIS_PASSWORD} ]; then fi # disable default user -if [ $(echo ${REDIS_DISABLE_DEFAULT_USER}) == "true" ]; then +if [ "$REDIS_DISABLE_DEFAULT_USER" == "true" ]; then echo "user default off nopass nocommands" >> /usr/local/etc/redis/custom_aclfile.acl fi diff --git a/backend/search/base/controller.go b/backend/search/base/controller.go index bd8a6b474..02c637005 100644 --- a/backend/search/base/controller.go +++ b/backend/search/base/controller.go @@ -1,7 +1,9 @@ package base import ( + "log" "net/http" + "os" search_types "github.com/GenerateNU/sac/backend/search/types" "github.com/GenerateNU/sac/backend/utilities" @@ -30,6 +32,8 @@ func NewSearchController(searchService SearchServiceInterface) *SearchController // @Failure 500 {object} error // @Router /search/clubs [get] func (s *SearchController) SearchClubs(c *fiber.Ctx) error { + log.SetOutput(os.Stdout) + var searchQuery search_types.ClubSearchRequest if err := c.BodyParser(&searchQuery); err != nil { diff --git a/backend/server/server.go b/backend/server/server.go index ff5d7b608..402db57af 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -10,7 +10,8 @@ import ( "github.com/GenerateNU/sac/backend/database/store" "github.com/GenerateNU/sac/backend/integrations/oauth/soth/goog" - "github.com/GenerateNU/sac/backend/integrations/oauth/soth/msft" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth/msft_mobile" + "github.com/GenerateNU/sac/backend/integrations/oauth/soth/msft_web" auth "github.com/GenerateNU/sac/backend/entities/auth/base" categories "github.com/GenerateNU/sac/backend/entities/categories/base" @@ -56,16 +57,11 @@ func Init(db *gorm.DB, stores *store.Stores, integrations integrations.Integrati panic(fmt.Sprintf("Error registering custom validators: %s", err)) } - applicationURL := settings.Application.ApplicationURL() + msftWebProvider := msft_web.New(settings.MicrosoftWeb.Key, settings.MicrosoftWeb.Secret, fmt.Sprintf("%s/api/v1/auth/microsoftonline/callback", settings.Application.PublicURL), settings.MicrosoftWeb.Tenant) + msftMobileProvider := msft_mobile.New(settings.MicrosoftMobile.Key, "myapp://auth/callback", settings.MicrosoftMobile.Tenant) + googProvider := goog.New(settings.Google.Key, settings.Google.Secret, fmt.Sprintf("%s/api/v1/auth/google/callback", settings.Application.PublicURL)) - msftProvider := msft.New(settings.Microsft.Key, fmt.Sprintf("%s/api/v1/auth/microsoftonline/callback", applicationURL), settings.Microsft.Tenant) - googProvider := goog.New(settings.Google.Key, settings.Google.Secret, fmt.Sprintf("%s/api/v1/auth/google/callback", applicationURL)) - - authMiddleware := authMiddleware.New( - db, - validate, - msftProvider, - ) + authMiddleware := authMiddleware.New(db, validate) utilityMiddleware := utilityMiddleware.New(fiberpaginate.New(), stores.Limiter) @@ -86,8 +82,9 @@ func Init(db *gorm.DB, stores *store.Stores, integrations integrations.Integrati allRoutes(app, routeParams, auth.NewParams( - msftProvider, - applicationURL, + msftWebProvider, + msftMobileProvider, + settings.Application.PublicURL, apiv1, db, integrations.Email, @@ -112,7 +109,7 @@ func allRoutes(app *fiber.App, routeParams types.RouteParams, authParams auth.Pa search.SearchRoutes(routeParams) } -func newFiberApp(appSettings config.ApplicationSettings) *fiber.App { +func newFiberApp(settings config.ApplicationSettings) *fiber.App { app := fiber.New(fiber.Config{ JSONEncoder: go_json.Marshal, JSONDecoder: go_json.Unmarshal, @@ -122,7 +119,7 @@ func newFiberApp(appSettings config.ApplicationSettings) *fiber.App { app.Use(recover.New()) app.Use(cors.New(cors.Config{ - AllowOrigins: appSettings.ApplicationURL(), + AllowOrigins: settings.PublicURL, AllowCredentials: true, AllowHeaders: "Origin, Content-Type, Accept, Authorization", AllowMethods: "GET, POST, PUT, DELETE, OPTIONS", diff --git a/backend/utilities/platform.go b/backend/utilities/platform.go new file mode 100644 index 000000000..baf1a431e --- /dev/null +++ b/backend/utilities/platform.go @@ -0,0 +1,23 @@ +package utilities + +import ( + "github.com/gofiber/fiber/v2" +) + +type Platform string + +const ( + PlatformMobile Platform = "mobile" + PlatformWeb Platform = "web" +) + +func GetPlatform(c *fiber.Ctx) Platform { + switch c.Get("Platform", "") { + case "mobile": + return PlatformMobile + case "web": + return PlatformWeb + default: + return PlatformMobile + } +} diff --git a/config/.env.template b/config/.env.template index 5b5246928..7fc4702d0 100644 --- a/config/.env.template +++ b/config/.env.template @@ -1,6 +1,6 @@ SAC_APPLICATION_PORT="8080" SAC_APPLICATION_HOST="127.0.0.1" -SAC_APPLICATION_BASE_URL="http://127.0.0.1" +SAC_APPLICATION_PUBLIC_URL="http://127.0.0.1" SAC_DB_USERNAME="postgres" SAC_DB_PASSWORD="password" @@ -36,11 +36,6 @@ SAC_AWS_REGION="SAC_AWS_REGION" SAC_SUDO_PASSWORD="Password#!1" -SAC_AWS_BUCKET_NAME="SAC_AWS_BUCKET_NAME" -SAC_AWS_ID="SAC_AWS_ID" -SAC_AWS_SECRET="SAC_AWS_SECRET" -SAC_AWS_REGION="SAC_AWS_REGION" - SAC_RESEND_API_KEY="SAC_RESEND_API_KEY" SAC_CALENDAR_MAX_TERMINATION_DATE="12-31-2024" @@ -49,6 +44,9 @@ SAC_GOOGLE_OAUTH_KEY=GOOGLE_OAUTH_CLIENT_ID SAC_GOOGLE_OAUTH_SECRET=GOOGLE_OAUTH_CLIENT_SECRET SAC_GOOGLE_API_KEY=GOOGLE_API_KEY -SAC_MICROSOFT_OAUTH_KEY=test +SAC_MICROSOFT_OAUTH_WEB_KEY=test +SAC_MICROSOFT_OAUTH_WEB_SECRET=test + +SAC_MICROSOFT_OAUTH_MOBILE_KEY=test SAC_SEARCH_OPENAI_API_KEY="OPENAI_API_KEY" \ No newline at end of file diff --git a/deployment/Caddyfile b/deployment/Caddyfile new file mode 100644 index 000000000..062cdc7f2 --- /dev/null +++ b/deployment/Caddyfile @@ -0,0 +1,3 @@ +studentactivitycalendar.xyz { + reverse_proxy sac_webserver:8080 +} \ No newline at end of file diff --git a/deployment/compose.yml b/deployment/compose.yml new file mode 100644 index 000000000..fda9ec29e --- /dev/null +++ b/deployment/compose.yml @@ -0,0 +1,74 @@ +services: + # WEBSERVER + caddy: + image: caddy:latest + restart: unless-stopped + cap_add: + - NET_ADMIN + ports: + - 80:80 + - 443:443 + - 443:443/udp + - 8443:8443 + - 8443:8443/udp + volumes: + - $PWD/Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + webserver: + container_name: sac_webserver + build: + context: ../backend + dockerfile: ../backend/Dockerfile.server + env_file: + - .env.prod + + # REDIS + redis-db-cache: + build: + context: ../backend + dockerfile: ../backend/Dockerfile.redis + container_name: redis_db_cache + ports: + - "6379" + environment: + - REDIS_USERNAME=redis_db_cache + - REDIS_PASSWORD=redis_db_cache!#1 + - REDIS_DISABLE_DEFAULT_USER="true" + volumes: + - redis-db-cache-data:/data + redis-session: + build: + context: ../backend + dockerfile: ../backend/Dockerfile.redis + container_name: redis_session + ports: + - "6379" + environment: + - REDIS_USERNAME=${SAC_REDIS_SESSION_USERNAME} + - REDIS_PASSWORD=${SAC_REDIS_SESSION_PASSWORD} + - REDIS_DISABLE_DEFAULT_USER="true" + volumes: + - redis-session-data:/data + redis-limiter: + build: + context: ../backend + dockerfile: ../backend/Dockerfile.redis + container_name: redis_limiter + expose: + - "6379" + environment: + - REDIS_USERNAME=${SAC_REDIS_LIMITER_USERNAME} + - REDIS_PASSWORD=${SAC_REDIS_LIMITER_PASSWORD} + - REDIS_DISABLE_DEFAULT_USER="true" + volumes: + - redis-limiter-data:/data + +volumes: + redis-session-data: + redis-limiter-data: + redis-db-cache-data: + opensearch-data1: + caddy_data: + external: true + caddy_config: diff --git a/deployment/init-db.sh b/deployment/init-db.sh new file mode 100644 index 000000000..486b86058 --- /dev/null +++ b/deployment/init-db.sh @@ -0,0 +1,2 @@ +go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest +~/go/bin/migrate -path ../backend/migrations/ -database postgres://${SAC_DB_USERNAME}:${SAC_DB_PASSWORD}@${SAC_DB_HOST}:${SAC_DB_PORT}/${SAC_DB_NAME}?sslmode=require -verbose up \ No newline at end of file diff --git a/deployment/setup.sh b/deployment/setup.sh new file mode 100644 index 000000000..7ea1eaf65 --- /dev/null +++ b/deployment/setup.sh @@ -0,0 +1,18 @@ +sudo yum update -y +sudo yum install -y docker git golang +sudo systemctl enable --now docker +sudo usermod -a -G docker ec2-user + +# Install the docker compose plugin for all users +sudo mkdir -p /usr/local/lib/docker/cli-plugins + +sudo curl -sL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-"$(uname -m)" \ + -o /usr/local/lib/docker/cli-plugins/docker-compose + +# Set ownership to root and make executable +test -f /usr/local/lib/docker/cli-plugins/docker-compose \ + && sudo chown root:root /usr/local/lib/docker/cli-plugins/docker-compose +test -f /usr/local/lib/docker/cli-plugins/docker-compose \ + && sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + +git clone -b prod-business https://github.com/GenerateNU/sac diff --git a/frontend/lib/package.json b/frontend/lib/package.json index fd4707354..ada010f6c 100644 --- a/frontend/lib/package.json +++ b/frontend/lib/package.json @@ -1,6 +1,6 @@ { "name": "@generatesac/lib", - "version": "0.0.171", + "version": "0.0.189", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/frontend/lib/src/api/authApi.ts b/frontend/lib/src/api/authApi.ts index e16cd6fef..bc6a0400d 100644 --- a/frontend/lib/src/api/authApi.ts +++ b/frontend/lib/src/api/authApi.ts @@ -1,69 +1,42 @@ -import { LoginRequestBody, RefreshTokenRequestBody } from "../types/auth"; -import { User, userSchema } from "../types/user"; -import { - EmailRequestBody, - VerifyEmailRequestBody, - VerifyPasswordResetTokenRequestBody, -} from "../types/verification"; -import { baseApi } from "./base"; +import { User, userSchema } from "../types"; +import { LoginResponse, OAuthCallbackRequestQueryParams } from "../types/auth"; +import { baseApi, handleQueryParams } from "./base"; const AUTH_API_BASE_URL = "/auth"; +const PROVIDER = "microsoftonlineweb"; export const authApi = baseApi.injectEndpoints({ endpoints: (builder) => ({ - login: builder.mutation({ - query: (body) => ({ + login: builder.query({ + query: () => ({ url: `${AUTH_API_BASE_URL}/login`, - method: "POST", - body, + method: "GET", + responseHandler: 'text', }), - transformResponse: (response: User) => { - return userSchema.parse(response); + transformResponse: async (_, meta) => { + const redirectUri = meta?.response?.headers.get("Redirect") as string; + const sac_session = meta?.response?.headers.get("_sac_session") as string; + + return { + redirect_uri: redirectUri, + sac_session, + } }, }), - logout: builder.mutation({ + logout: builder.query({ query: () => ({ url: `${AUTH_API_BASE_URL}/logout`, - method: "POST", - }), - }), - refresh: builder.mutation({ - query: (body) => ({ - url: "refresh", - method: "POST", - body, - }), - }), - forgotPassword: builder.mutation({ - query: (body) => ({ - url: `${AUTH_API_BASE_URL}/forgot-password`, - method: "POST", - body, - }), - }), - verifyPasswordResetToken: builder.mutation< - void, - VerifyPasswordResetTokenRequestBody - >({ - query: (body) => ({ - url: `${AUTH_API_BASE_URL}/verify-reset`, - method: "POST", - body, + method: "GET", }), }), - sendCode: builder.mutation({ - query: (body) => ({ - url: `${AUTH_API_BASE_URL}/send-code`, - method: "POST", - body, - }), - }), - verifyEmail: builder.mutation({ - query: (body) => ({ - url: `${AUTH_API_BASE_URL}/verify-email`, - method: "POST", - body, + callback: builder.query({ + query: (params) => ({ + url: handleQueryParams(`${AUTH_API_BASE_URL}/${PROVIDER}/callback`, params), + method: "GET", }), + transformResponse: (response) => { + return userSchema.parse(response); + } }), }), }); diff --git a/frontend/lib/src/api/base.ts b/frontend/lib/src/api/base.ts index 9a32ddfa9..17bccad26 100644 --- a/frontend/lib/src/api/base.ts +++ b/frontend/lib/src/api/base.ts @@ -1,18 +1,28 @@ import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; -export const API_BASE_URL = "http://127.0.0.1:8080/api/v1"; +export const LOCAL_API_BASE_URL = "http://127.0.0.1:8080/api/v1"; +export const PROD_API_BASE_URL = "https://studentactivitycalendar.xyz/api/v1"; + +type GlobalState = { + platform: "web" | "mobile"; + loggedIn: boolean; + accessToken?: string; +}; // BaseAPI for the entire application: export const baseApi = createApi({ baseQuery: fetchBaseQuery({ - baseUrl: API_BASE_URL, + baseUrl: LOCAL_API_BASE_URL, credentials: "include", prepareHeaders: async (headers, { getState }) => { // User slice existing must exist in all dependent apps: - const token = (getState() as { user: { accessToken: string } })?.user?.accessToken; - if (token) { - headers.set("Authorization", `Bearer ${token}`); + const globalState = (getState() as { global: GlobalState })?.global; + if (globalState.accessToken) { + // Pass the authentication header: + headers.set("_sac_session", globalState.accessToken); } + // Set the platform header: + headers.set("Platform", globalState.platform); return headers; }, }), diff --git a/frontend/lib/src/api/clubApi.ts b/frontend/lib/src/api/clubApi.ts index 70d9f8d3f..4d6c54473 100644 --- a/frontend/lib/src/api/clubApi.ts +++ b/frontend/lib/src/api/clubApi.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { Club, + CreateClubFollower, CreateClubRequestBody, CreateClubTagsRequestBody, UpdateClubRequestBody, @@ -132,6 +133,22 @@ export const clubApi = baseApi.injectEndpoints({ return z.array(eventSchema).parse(response); }, }), + createClubFollower: builder.mutation({ + query: ({ club_id, user_id }) => ({ + url: `${CLUB_API_BASE_URL}/${club_id}/followers/${user_id}`, + method: "POST", + responseHandler: "text" + }), + invalidatesTags: ["Follower"], + }), + deleteClubFollower: builder.mutation({ + query: ({ club_id, user_id }) => ({ + url: `${CLUB_API_BASE_URL}/${club_id}/followers/${user_id}`, + method: "DELETE", + responseHandler: "text" + }), + invalidatesTags: ["Follower"], + }), clubFollowers: builder.query< User[], { id: string; queryParams?: PaginationQueryParams } @@ -154,47 +171,6 @@ export const clubApi = baseApi.injectEndpoints({ return z.array(userSchema).parse(response); }, }), - clubMembers: builder.query< - User[], - { id: string; queryParams?: PaginationQueryParams } - >({ - query: ({ id, queryParams }) => ({ - url: handleQueryParams( - `${CLUB_API_BASE_URL}/${id}/members/`, - queryParams, - ), - method: "GET", - }), - providesTags: (result) => - result - ? result.map((member) => ({ type: "User", id: member.id })) - : ["User"], - transformResponse: (response) => { - return z.array(userSchema).parse(response); - }, - }), - createClubMember: builder.mutation< - User, - { clubID: string; userID: string } - >({ - query: ({ clubID, userID }) => ({ - url: `${CLUB_API_BASE_URL}/${clubID}/members/${userID}`, - method: "POST", - }), - invalidatesTags: (result, _, { userID }) => - result ? [{ type: "User", id: userID }] : [], - }), - deleteClubMember: builder.mutation< - void, - { clubID: string; userID: string } - >({ - query: ({ clubID, userID }) => ({ - url: `${CLUB_API_BASE_URL}/${clubID}/members/${userID}`, - method: "DELETE", - }), - invalidatesTags: (result, _, { userID }) => - result ? [{ type: "User", id: userID }] : [], - }), clubPointOfContacts: builder.query({ query: (id) => ({ url: `${CLUB_API_BASE_URL}/${id}/pocs/`, diff --git a/frontend/lib/src/api/eventApi.ts b/frontend/lib/src/api/eventApi.ts index fc45474da..1fdec87b3 100644 --- a/frontend/lib/src/api/eventApi.ts +++ b/frontend/lib/src/api/eventApi.ts @@ -5,13 +5,15 @@ import { CreateEventRequestBody, Event, EventPreview, + EventUserParams, UpdateEventRequestBody, eventPreviewSchema, eventSchema, } from "../types/event"; import { PaginationQueryParams } from "../types/root"; -import { Tag } from "../types/tag"; +import { Tag, tagSchema } from "../types/tag"; import { baseApi, handleQueryParams } from "./base"; +import { User, userSchema } from "../types"; const EVENT_API_BASE_URL = "/events"; @@ -102,6 +104,32 @@ export const eventApi = baseApi.injectEndpoints({ }), providesTags: (result) => result ? result.map((tag) => ({ type: "Tag", id: tag.id })) : ["Tag"], + transformResponse: (response) => { + return z.array(tagSchema).parse(response); + } + }), + createEventRegistration: builder.mutation({ + query: ({ user_id, event_id }) => ({ + url: `${EVENT_API_BASE_URL}/${event_id}/rsvps/${user_id}`, + method: "POST", + responseHandler: 'text' + }), + }), + deleteEventRegistration: builder.mutation({ + query: ({ user_id, event_id }) => ({ + url: `${EVENT_API_BASE_URL}/${event_id}/rsvps/${user_id}`, + method: "DELETE", + responseHandler: 'text', + }), + }), + eventRegistrations: builder.query({ + query: (id) => ({ + url: `${EVENT_API_BASE_URL}/${id}/rsvps`, + method: "GET", + }), + transformResponse: (response) => { + return z.array(userSchema).parse(response); + }, }), }), }); diff --git a/frontend/lib/src/api/userApi.ts b/frontend/lib/src/api/userApi.ts index 766491a2e..76c5cfb0c 100644 --- a/frontend/lib/src/api/userApi.ts +++ b/frontend/lib/src/api/userApi.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import { UpdatePasswordRequestBody } from "../types/auth"; -import { Club, clubSchema } from "../types/club"; import { PaginationQueryParams } from "../types/root"; import { Tag, tagSchema } from "../types/tag"; import { @@ -12,6 +10,7 @@ import { userSchema, } from "../types/user"; import { baseApi, handleQueryParams } from "./base"; +import { Club, clubSchema } from "../types"; const USER_API_BASE_URL = "/users"; @@ -82,64 +81,6 @@ export const userApi = baseApi.injectEndpoints({ }), invalidatesTags: (_result, _, id) => [{ type: "User", id }], }), - updatePassword: builder.mutation< - void, - { id: string; body: UpdatePasswordRequestBody } - >({ - query: ({ id, body }) => ({ - url: `${USER_API_BASE_URL}/${id}/password`, - method: "PATCH", - body, - }), - }), - userFollowing: builder.query({ - query: (id) => ({ - url: `${USER_API_BASE_URL}/${id}/follower/`, - method: "GET", - }), - providesTags: (result, _, id) => - result - ? [{ type: "Follower", id }, "Club"] - : [{ type: "Follower", id }], - transformResponse: (response) => { - return z.array(clubSchema).parse(response); - }, - }), - createUserFollowing: builder.mutation< - void, - { userID: string; clubID: string } - >({ - query: ({ userID, clubID }) => ({ - url: `${USER_API_BASE_URL}/${userID}/follower/${clubID}`, - method: "POST", - }), - invalidatesTags: (_result, _, { userID }) => [ - { type: "Follower", id: userID }, - ], - }), - deleteUserFollowing: builder.mutation< - void, - { userID: string; clubID: string } - >({ - query: ({ userID, clubID }) => ({ - url: `${USER_API_BASE_URL}/${userID}/follower/${clubID}`, - method: "DELETE", - }), - invalidatesTags: (_result, _, { userID }) => [ - { type: "Follower", id: userID }, - ], - }), - userMembership: builder.query({ - query: (id) => ({ - url: `${USER_API_BASE_URL}/${id}/member/`, - method: "GET", - }), - providesTags: (result, _, id) => - result ? [{ type: "Member", id }, "Club"] : [{ type: "Member", id }], - transformResponse: (response) => { - return z.array(clubSchema).parse(response); - }, - }), userTags: builder.query({ query: () => ({ url: `${USER_API_BASE_URL}/tags/`, @@ -170,5 +111,14 @@ export const userApi = baseApi.injectEndpoints({ }), invalidatesTags: (_result, _, id) => [{ type: "Tag", id }], }), + getUserFollowing: builder.query({ + query: (id) => ({ + url: `${USER_API_BASE_URL}/${id}/follower`, + method: "GET", + }), + transformResponse: (response) => { + return z.array(clubSchema).parse(response); + }, + }) }), }); diff --git a/frontend/lib/src/types/auth.ts b/frontend/lib/src/types/auth.ts index e45d7af5f..c3eaec616 100644 --- a/frontend/lib/src/types/auth.ts +++ b/frontend/lib/src/types/auth.ts @@ -1,31 +1,16 @@ import { z } from "zod"; -// Schemas: -export const loginRequestBodySchema = z.object({ - email: z.string().email(), - password: z.string().min(8), +export const loginResponseSchema = z.object({ + redirect_uri: z.string(), + sac_session: z.string(), }); -export const updatePasswordRequestBodySchema = z.object({ - old_password: z.string().min(8), - new_password: z.string().min(8), -}); - -export const refreshTokenRequestBodySchema = z.object({ - refresh_token: z.string(), -}); - -export const tokensSchema = z.object({ - access_token: z.string(), - refresh_token: z.string(), +export const oauthCallbackRequestQueryParams = z.object({ + code: z.string(), + session_state: z.string(), + state: z.string(), }); // Types: -export type LoginRequestBody = z.infer; -export type UpdatePasswordRequestBody = z.infer< - typeof updatePasswordRequestBodySchema ->; -export type RefreshTokenRequestBody = z.infer< - typeof refreshTokenRequestBodySchema ->; -export type Tokens = z.infer; +export type LoginResponse = z.infer; +export type OAuthCallbackRequestQueryParams = z.infer; diff --git a/frontend/lib/src/types/club.ts b/frontend/lib/src/types/club.ts index 20e871cf8..cc67c8a7d 100644 --- a/frontend/lib/src/types/club.ts +++ b/frontend/lib/src/types/club.ts @@ -42,6 +42,11 @@ const clubSchemaIntermediate = z.object({ recruitment: recruitmentSchema.optional(), }); +const createClubFollowerSchema = z.object({ + club_id: z.string().uuid(), + user_id: z.string().uuid(), +}); + export const clubSchema = clubSchemaIntermediate.merge(rootModelSchema); // Types: @@ -50,4 +55,5 @@ export type UpdateClubRequestBody = z.infer; export type CreateClubTagsRequestBody = z.infer< typeof createClubTagsRequestBodySchema >; +export type CreateClubFollower = z.infer; export type Club = z.infer; diff --git a/frontend/lib/src/types/event.ts b/frontend/lib/src/types/event.ts index 5618ecd5a..62cc0e5be 100644 --- a/frontend/lib/src/types/event.ts +++ b/frontend/lib/src/types/event.ts @@ -64,6 +64,11 @@ const eventPreviewSchemaIntermediate = z.object({ host_logo: z.string().max(255).optional(), }); +const eventUserParams = z.object({ + event_id: z.string().uuid(), + user_id: z.string().uuid(), +}) + export const eventSchema = eventSchemaIntermediate.merge(rootModelSchema); export const eventPreviewSchema = eventPreviewSchemaIntermediate @@ -77,3 +82,4 @@ export type UpdateEventRequestBody = z.infer< export type Event = z.infer; export type EventPreview = z.infer; export type EventType = z.infer; +export type EventUserParams = z.infer; diff --git a/frontend/lib/src/types/user.ts b/frontend/lib/src/types/user.ts index b2e9b1572..cc18f52d8 100644 --- a/frontend/lib/src/types/user.ts +++ b/frontend/lib/src/types/user.ts @@ -6,6 +6,7 @@ import { rootModelSchema } from "./root"; export const userRoleEnum = z.enum(["super", "student"]); export const collegeEnum = z.enum([ + "", "CAMD", "DMSB", "KCCS", @@ -18,6 +19,7 @@ export const collegeEnum = z.enum([ ]); export const majorEnum = z.enum([ + "", "africanaStudies", "americanSignLanguage", "americanSignLanguage-EnglishInterpreting", @@ -120,7 +122,7 @@ export const majorEnum = z.enum([ "theatre", ]); -export const graduationCycleEnum = z.enum(["december", "may"]); +export const graduationCycleEnum = z.enum(["december", "may", ""]); export const yearEnum = z.enum(["1", "2", "3", "4", "5"]); @@ -155,8 +157,7 @@ export const createUserTagsRequestBodySchema = z.object({ const userSchemaIntermediate = z.object({ role: userRoleEnum, - first_name: z.string().min(1), - last_name: z.string().min(1), + name: z.string().min(1), email: z.string().email(), major0: majorEnum.optional(), major1: majorEnum.optional(), @@ -164,7 +165,6 @@ const userSchemaIntermediate = z.object({ college: collegeEnum.optional(), graduation_cycle: graduationCycleEnum.optional(), graduation_year: z.number().optional(), - is_verified: z.boolean(), }); export const userSchema = userSchemaIntermediate.merge(rootModelSchema); diff --git a/frontend/mobile/app.json b/frontend/mobile/app.json index 4bb2fce5a..fc5066552 100644 --- a/frontend/mobile/app.json +++ b/frontend/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "sac-mobile", "slug": "student-activity-calendar", - "version": "1.0.3", + "version": "1.0.4", "orientation": "portrait", "icon": "./src/assets/images/icon.png", "scheme": "myapp", diff --git a/frontend/mobile/package.json b/frontend/mobile/package.json index d2ee424e5..49bc48797 100644 --- a/frontend/mobile/package.json +++ b/frontend/mobile/package.json @@ -25,7 +25,7 @@ "@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/react-fontawesome": "^0.2.2", "@fortawesome/react-native-fontawesome": "^0.3.2", - "@generatesac/lib": "0.0.171", + "@generatesac/lib": "0.0.189", "@gorhom/bottom-sheet": "^4.6.3", "@hookform/resolvers": "^3.4.2", "@react-native-async-storage/async-storage": "^1.23.1", diff --git a/frontend/mobile/src/app/(app)/(tabs)/index.tsx b/frontend/mobile/src/app/(app)/(tabs)/index.tsx deleted file mode 100644 index 7c612d70e..000000000 --- a/frontend/mobile/src/app/(app)/(tabs)/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; - -const HomePage = () => { - return <>; -}; - -export default HomePage; diff --git a/frontend/mobile/src/app/(app)/(tabs)/mockClubPageData.ts b/frontend/mobile/src/app/(app)/(tabs)/mockClubPageData.ts deleted file mode 100644 index 8e276f166..000000000 --- a/frontend/mobile/src/app/(app)/(tabs)/mockClubPageData.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { Club, Contact, Event, PointOfContact, Tag } from '@generatesac/lib'; - -const mockClub: Club = { - id: '1', - name: 'Mock Club', - preview: 'This is a mock club for demonstration purposes.', - description: - 'A mock club that serves as an example for the ClubPage component.', - is_recruiting: true, - recruitment_cycle: 'fall', - recruitment_type: 'application', - application_link: 'https://example.com/apply', - num_members: 50, - created_at: new Date(), - updated_at: new Date(), - logo: 'https://example.com/logo.png', - weekly_time_committment: 5, - one_word_to_describe_us: 'Innovative' -}; - -const mockTags: Tag[] = [ - { - id: '1', - name: 'Technology', - category_id: '1', - created_at: new Date(), - updated_at: new Date() - }, - { - id: '2', - name: 'Community', - category_id: '2', - created_at: new Date(), - updated_at: new Date() - }, - { - id: '3', - name: 'Social', - category_id: '3', - created_at: new Date(), - updated_at: new Date() - }, - { - id: '4', - name: 'Professional', - category_id: '4', - created_at: new Date(), - updated_at: new Date() - }, - { - id: '5', - name: 'Academic', - category_id: '5', - created_at: new Date(), - updated_at: new Date() - } -]; - -const mockEvents: Event[] = [ - { - id: '1', - name: 'Mock Event 1', - preview: 'This is the first mock event.', - content: 'Details about the first mock event.', - start_time: new Date(), - end_time: new Date(), - location: 'Online', - event_type: 'open', - is_recurring: false, - created_at: new Date(), - updated_at: new Date(), - host: 'Mock Host 1', - meeting_link: 'https://example.com/meeting1' - }, - { - id: '2', - name: 'Mock Event 2', - preview: 'This is the second mock event.', - content: 'Details about the second mock event.', - start_time: new Date(), - end_time: new Date(), - location: 'Offline', - event_type: 'membersOnly', - is_recurring: true, - created_at: new Date(), - updated_at: new Date(), - host: 'Mock Host 2', - meeting_link: 'https://example.com/meeting2' - }, - { - id: '3', - name: 'Mock Event 3', - preview: 'This is the third mock event.', - content: 'Details about the third mock event.', - start_time: new Date(), - end_time: new Date(), - location: 'Online', - event_type: 'open', - is_recurring: false, - created_at: new Date(), - updated_at: new Date(), - host: 'Mock Host 3', - meeting_link: 'https://example.com/meeting3' - }, - { - id: '4', - name: 'Mock Event 4', - preview: 'This is the fourth mock event.', - content: 'Details about the fourth mock event.', - start_time: new Date(), - end_time: new Date(), - location: 'Offline', - event_type: 'membersOnly', - is_recurring: true, - created_at: new Date(), - updated_at: new Date(), - host: 'Mock Host 4', - meeting_link: 'https://example.com/meeting4' - }, - { - id: '5', - name: 'Mock Event 5', - preview: 'This is the fifth mock event.', - content: 'Details about the fifth mock event.', - start_time: new Date(), - end_time: new Date(), - location: 'Online', - event_type: 'open', - is_recurring: false, - created_at: new Date(), - updated_at: new Date(), - host: 'Mock Host 5', - meeting_link: 'https://example.com/meeting5' - } -]; - -const mockContacts: Contact[] = [ - { - id: '1', - type: 'email', - content: 'contact@example.com', - created_at: new Date(), - updated_at: new Date() - }, - { - id: '2', - type: 'discord', - content: 'https://discord.gg/example', - created_at: new Date(), - updated_at: new Date() - } -]; - -const pointOfContactMocks: PointOfContact[] = [ - { - name: 'Jane Smith', - email: 'jane.smith@example.com', - position: 'Director', - photo_file: { - owner_id: 'd4f5a6e7-8b9c-10d1-1121-314151617181', - owner_type: 'user', - file_name: 'photo1.jpg', - file_type: 'image/jpeg', - file_size: 2048, - file_url: '/photos/photo1.jpg', - object_key: 'photos/photo1.jpg', - id: '12345', - created_at: new Date('2023-05-01T00:00:00Z'), - updated_at: new Date('2023-05-02T00:00:00Z') - }, - id: '54321', - created_at: new Date('2023-05-01T00:00:00Z'), - updated_at: new Date('2023-05-02T00:00:00Z') - }, - { - name: 'John Doe', - email: 'john.doe@example.com', - position: 'Manager', - photo_file: { - owner_id: 'd4f5a6e7-8b9c-10d1-1121-314151617182', - owner_type: 'user', - file_name: 'photo2.jpg', - file_type: 'image/jpeg', - file_size: 1024, - file_url: '/photos/photo2.jpg', - object_key: 'photos/photo2.jpg', - id: '12346', - created_at: new Date('2023-05-01T00:00:00Z'), - updated_at: new Date('2023-05-02T00:00:00Z') - }, - id: '54322', - created_at: new Date('2023-05-01T00:00:00Z'), - updated_at: new Date('2023-05-02T00:00:00Z') - }, - { - name: 'Alice Johnson', - email: 'alice.johnson@example.com', - position: 'Team Lead', - photo_file: { - owner_id: 'd4f5a6e7-8b9c-10d1-1121-314151617183', - owner_type: 'user', - file_name: 'photo3.jpg', - file_type: 'image/jpeg', - file_size: 3072, - file_url: '/photos/photo3.jpg', - object_key: 'photos/photo3.jpg', - id: '12347', - created_at: new Date('2023-05-01T00:00:00Z'), - updated_at: new Date('2023-05-02T00:00:00Z') - }, - id: '54323', - created_at: new Date('2023-05-01T00:00:00Z'), - updated_at: new Date('2023-05-02T00:00:00Z') - }, - { - name: 'Bob Brown', - email: 'bob.brown@example.com', - position: 'Senior Developer', - photo_file: { - owner_id: 'd4f5a6e7-8b9c-10d1-1121-314151617184', - owner_type: 'user', - file_name: 'photo4.jpg', - file_type: 'image/jpeg', - file_size: 4096, - file_url: '/photos/photo4.jpg', - object_key: 'photos/photo4.jpg', - id: '12348', - created_at: new Date('2023-05-01T00:00:00Z'), - updated_at: new Date('2023-05-02T00:00:00Z') - }, - id: '54324', - created_at: new Date('2023-05-01T00:00:00Z'), - updated_at: new Date('2023-05-02T00:00:00Z') - }, - { - name: 'Clara White', - email: 'clara.white@example.com', - position: 'Product Manager', - photo_file: { - owner_id: 'd4f5a6e7-8b9c-10d1-1121-314151617185', - owner_type: 'user', - file_name: 'photo5.jpg', - file_type: 'image/jpeg', - file_size: 5120, - file_url: '/photos/photo5.jpg', - object_key: 'photos/photo5.jpg', - id: '12349', - created_at: new Date('2023-05-01T00:00:00Z'), - updated_at: new Date('2023-05-02T00:00:00Z') - }, - id: '54325', - created_at: new Date('2023-05-01T00:00:00Z'), - updated_at: new Date('2023-05-02T00:00:00Z') - } -]; - -export { mockClub, mockTags, mockEvents, mockContacts, pointOfContactMocks }; diff --git a/frontend/mobile/src/app/(app)/user/_layout.tsx b/frontend/mobile/src/app/(app)/user/_layout.tsx deleted file mode 100644 index be0d2d5e9..000000000 --- a/frontend/mobile/src/app/(app)/user/_layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import { Stack } from 'expo-router'; - -const Layout = () => { - return ( - - - - - ); -}; - -export default Layout; diff --git a/frontend/mobile/src/app/(auth)/components/wordmark.tsx b/frontend/mobile/src/app/(auth)/components/wordmark.tsx deleted file mode 100644 index 1438c2242..000000000 --- a/frontend/mobile/src/app/(auth)/components/wordmark.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Text } from '@/src/app/(design-system)'; - -interface WordmarkProps { - color?: 'black' | 'white'; -} - -export const Wordmark: React.FC = ({ color = 'black' }) => { - return ( - - Wordmark - - ); -}; diff --git a/frontend/mobile/src/app/(auth)/index.tsx b/frontend/mobile/src/app/(auth)/index.tsx deleted file mode 100644 index 870d17110..000000000 --- a/frontend/mobile/src/app/(auth)/index.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React, { useRef, useState } from 'react'; -import { - Animated, - Dimensions, - ScrollView, - TouchableOpacity -} from 'react-native'; - -import { Stack, router } from 'expo-router'; - -import { - faCalendarDays, - faMagnifyingGlass, - faUserPlus -} from '@fortawesome/free-solid-svg-icons'; - -import { Box, Text } from '../(design-system)'; -import { Button } from '../(design-system)'; -import { Screen, ScreenProps } from './components/splash-screen'; -import { Wordmark } from './components/wordmark'; - -const WelcomePage = () => { - const [screen, setScreen] = useState(0); - const scrollX = useRef(new Animated.Value(0)).current; - const scrollViewRef = useRef(null); - - const screens: ScreenProps[] = [ - { - title: 'Discover clubs & events', - description: - 'Explore clubs and events tailored to your interests and searches', - icon: faMagnifyingGlass - }, - { - title: 'Follow & join clubs', - description: - 'Join clubs and be in the know on their upcoming events and latest news', - icon: faUserPlus - }, - { - title: 'Add events to your calendar', - description: - 'Keep track of all your upcoming events by adding them to your calendar', - icon: faCalendarDays - } - ]; - - const { width } = Dimensions.get('window'); - const handleNext = () => { - if (screen < screens.length - 1) { - setScreen(screen + 1); - scrollViewRef.current?.scrollTo({ - x: width * (screen + 1), - animated: true - }); - } else { - router.push('/(app)/'); - } - }; - - return ( - - , - headerRight: () => ( - router.push('/(app)/')} - > - Skip - - ) - }} - /> - - - - {screens.map((screenItem, index) => ( - - - - ))} - - - - - {screens.map((_, i) => { - const fillWidth = scrollX.interpolate({ - inputRange: [ - width * (i - 1), - width * i, - width * (i + 1) - ], - outputRange: [0, 20, 20], - extrapolate: 'clamp' - }) as Animated.AnimatedInterpolation; - return ( - - - - ); - })} - - - - - - ); -}; - -export default WelcomePage; diff --git a/frontend/mobile/src/app/_layout.tsx b/frontend/mobile/src/app/_layout.tsx index f35b22cc3..e26912e01 100644 --- a/frontend/mobile/src/app/_layout.tsx +++ b/frontend/mobile/src/app/_layout.tsx @@ -13,14 +13,14 @@ import { ThemeProvider } from '@shopify/restyle'; import usePreview from '../hooks/usePreview'; import StoreProvider from '../store/StoreProvider'; import { useAppSelector } from '../store/store'; -import { ClubPreview, EventPreview, theme } from './(design-system)'; +import { ClubPreview, EventPreview, theme } from './design-system'; export { ErrorBoundary } from 'expo-router'; SplashScreen.preventAutoHideAsync(); const InitalLayout = () => { - const { accessToken } = useAppSelector((state) => state.user); + const { accessToken, loggedIn } = useAppSelector((state) => state.global); const { eventPreviewRef, eventId, @@ -33,19 +33,20 @@ const InitalLayout = () => { } = usePreview(); useEffect(() => { - if (!accessToken) { - router.push('/(app)/'); + if (accessToken && loggedIn) { + router.push('/app/'); } else { - router.push('/(app)/profile'); + router.push('/auth/'); } - }, [accessToken]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loggedIn]); return ( <> - - + + { backgroundColor: 'white' }} > - - TabBarLabel({ focused, title: 'Home' }), - tabBarIcon: ({ focused }) => - TabBarIcon({ focused, icon: faHouse }) - }} - /> ( ); const ProfilePage = () => { + const dispatch = useAppDispatch(); + const user = useAppSelector((state) => state.user); + return ( @@ -52,28 +59,31 @@ const ProfilePage = () => { gap="m" alignItems="center" > - - Quokka - quokka@northeastern.edu + {user.name} + {user.email} - router.push('/user/detail/')} + {/* router.push('/app/user/detail/')} icon={faUser} text="Edit Profile" /> router.push('/user/interest/')} + onPress={() => router.push('/app/user/interest/')} text="Edit Interests" + /> */} + router.push('/app/user/following/')} + text="Following" + /> + router.push('/app/user/events/')} + text="Upcoming Events" /> { icon={faSignOutAlt} text="Logout" textColor="darkRed" + onPress={() => { + dispatch(resetAccessToken()); + dispatch(resetEvent()); + dispatch(resetUser()); + dispatch(resetClub()); + }} /> @@ -94,7 +110,6 @@ const ProfilePage = () => { const styles = StyleSheet.create({ container: { - flex: 1, flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'flex-start', diff --git a/frontend/mobile/src/app/(app)/_layout.tsx b/frontend/mobile/src/app/app/_layout.tsx similarity index 100% rename from frontend/mobile/src/app/(app)/_layout.tsx rename to frontend/mobile/src/app/app/_layout.tsx diff --git a/frontend/mobile/src/app/(app)/club/[id].tsx b/frontend/mobile/src/app/app/club/[id].tsx similarity index 80% rename from frontend/mobile/src/app/(app)/club/[id].tsx rename to frontend/mobile/src/app/app/club/[id].tsx index 5491ffa73..dd2858400 100644 --- a/frontend/mobile/src/app/(app)/club/[id].tsx +++ b/frontend/mobile/src/app/app/club/[id].tsx @@ -10,6 +10,7 @@ import Animated, { import { Stack, useLocalSearchParams } from 'expo-router'; import { faExternalLink } from '@fortawesome/free-solid-svg-icons'; +import { clubApi } from '@generatesac/lib'; import BottomSheet from '@gorhom/bottom-sheet'; import { @@ -18,18 +19,19 @@ import { PageTags, RecruitmentInfo, Text -} from '@/src/app/(design-system)'; -import { SACColors } from '@/src/app/(design-system)'; -import { Button } from '@/src/app/(design-system)/components/Button/Button'; +} from '@/src/app/design-system'; +import { SACColors } from '@/src/app/design-system'; +import { Button } from '@/src/app/design-system/components/Button/Button'; import useClub from '@/src/hooks/useClub'; -import { useAppSelector } from '@/src/store/store'; +import { setUserFollowing } from '@/src/store/slices/userSlice'; +import { useAppDispatch, useAppSelector } from '@/src/store/store'; -import { AboutSection } from '../../(design-system)/components/AboutSection/AboutSection'; -import AnimatedImageHeader from '../../(design-system)/components/AnimatedImageHeader/AnimatedImageHeader'; -import { ClubIcon } from '../../(design-system)/components/ClubIcon/ClubIcon'; -import { EventCard } from '../../(design-system)/components/EventCard'; -import { EventCardList } from '../../(design-system)/components/EventCard/EventCardList'; -import PageError from '../../(design-system)/components/PageError/PageError'; +import { AboutSection } from '../../design-system/components/AboutSection/AboutSection'; +import AnimatedImageHeader from '../../design-system/components/AnimatedImageHeader/AnimatedImageHeader'; +import { ClubIcon } from '../../design-system/components/ClubIcon/ClubIcon'; +import { EventCard } from '../../design-system/components/EventCard'; +import { EventCardList } from '../../design-system/components/EventCard/EventCardList'; +import PageError from '../../design-system/components/PageError/PageError'; import { Description } from '../event/components/description'; import ClubPageSkeleton from './components/skeleton'; @@ -46,7 +48,35 @@ const ClubPage = () => { const bottomSheet = useRef(null); const club = useAppSelector((state) => state.club); - const { setRetriggerFetch, apiLoading, apiError } = useClub(id as string); + const { id: user_id, following } = useAppSelector((state) => state.user); + const dispatch = useAppDispatch(); + const { setRetriggerFetch, apiLoading, apiError } = useClub(id); + const [followClub] = clubApi.useCreateClubFollowerMutation(); + const [unFollowClub] = clubApi.useDeleteClubFollowerMutation(); + + const clubFollowed = following.includes(id as string); + + const handleUserFollow = (follow: boolean) => { + if (id) { + if (follow) { + followClub({ club_id: id, user_id }).then(({ error }) => { + if (!error) { + dispatch(setUserFollowing([...following, id])); + } + }); + } else { + unFollowClub({ club_id: id, user_id }).then(({ error }) => { + if (!error) { + dispatch( + setUserFollowing( + following.filter((clubId) => clubId !== id) + ) + ); + } + }); + } + } + }; const headerAnimatedStyle = useAnimatedStyle(() => { return { @@ -85,7 +115,7 @@ const ClubPage = () => { scrollEventThrottle={16} ref={scrollRef} > - {apiLoading ? ( + {apiLoading || club.id !== id ? ( ) : apiError ? ( @@ -112,8 +142,11 @@ const ClubPage = () => { color={color} size="sm" variant="standardButton" + onPress={() => + handleUserFollow(!clubFollowed) + } > - Follow + {clubFollowed ? 'Unfollow' : 'Follow'} diff --git a/frontend/mobile/src/app/(app)/club/_layout.tsx b/frontend/mobile/src/app/app/club/_layout.tsx similarity index 100% rename from frontend/mobile/src/app/(app)/club/_layout.tsx rename to frontend/mobile/src/app/app/club/_layout.tsx diff --git a/frontend/mobile/src/app/(app)/club/components/skeleton.tsx b/frontend/mobile/src/app/app/club/components/skeleton.tsx similarity index 96% rename from frontend/mobile/src/app/(app)/club/components/skeleton.tsx rename to frontend/mobile/src/app/app/club/components/skeleton.tsx index 0e1cf3c9f..adcc12c75 100644 --- a/frontend/mobile/src/app/(app)/club/components/skeleton.tsx +++ b/frontend/mobile/src/app/app/club/components/skeleton.tsx @@ -1,6 +1,6 @@ import { Skeleton } from '@rneui/base'; -import { Box, Colors } from '@/src/app/(design-system)'; +import { Box, Colors } from '@/src/app/design-system'; const ClubPageSkeleton = () => { return ( diff --git a/frontend/mobile/src/app/(app)/event/[id].tsx b/frontend/mobile/src/app/app/event/[id].tsx similarity index 83% rename from frontend/mobile/src/app/(app)/event/[id].tsx rename to frontend/mobile/src/app/app/event/[id].tsx index e867f04f6..6a5565c25 100644 --- a/frontend/mobile/src/app/(app)/event/[id].tsx +++ b/frontend/mobile/src/app/app/event/[id].tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Dimensions } from 'react-native'; import Animated, { interpolate, @@ -9,18 +9,20 @@ import Animated, { import { Stack, useLocalSearchParams } from 'expo-router'; -import { EventType } from '@generatesac/lib'; +import { EventPreview, EventType, eventApi } from '@generatesac/lib'; import BottomSheet from '@gorhom/bottom-sheet'; -import { Arrow, Box, KebabMenu } from '@/src/app/(design-system)'; -import { SACColors } from '@/src/app/(design-system)'; -import { Button } from '@/src/app/(design-system)/components/Button/Button'; +import { Arrow, Box } from '@/src/app/design-system'; +import { SACColors } from '@/src/app/design-system'; +import { Button } from '@/src/app/design-system/components/Button/Button'; import { description, events, tags } from '@/src/consts/event-page'; import useEvent from '@/src/hooks/useEvent'; -import { useAppSelector } from '@/src/store/store'; +import { setEventId } from '@/src/store/slices/eventSlice'; +import { setUserRSVPs } from '@/src/store/slices/userSlice'; +import { useAppDispatch, useAppSelector } from '@/src/store/store'; -import AnimatedImageHeader from '../../(design-system)/components/AnimatedImageHeader/AnimatedImageHeader'; -import PageError from '../../(design-system)/components/PageError/PageError'; +import AnimatedImageHeader from '../../design-system/components/AnimatedImageHeader/AnimatedImageHeader'; +import PageError from '../../design-system/components/PageError/PageError'; import { AboutEvent } from './components/about'; import { Description } from './components/description'; import { Location } from './components/location'; @@ -58,12 +60,72 @@ const EventPage = () => { const scrollRef = useAnimatedRef(); const scrollOffset = useScrollViewOffset(scrollRef); + const dispatch = useAppDispatch(); + const { id: userId, rsvps } = useAppSelector((state) => state.user); const event = useAppSelector((state) => state.event); const { name: clubName, logo: clubLogo } = useAppSelector( (state) => state.club ); + const [userAttending, setUserAttending] = useState( + event.rsvps.includes(userId) + ); + + const { setRetriggerFetch, apiLoading, apiError } = useEvent(); + const [registerUser] = eventApi.useCreateEventRegistrationMutation(); + const [unregisterUser] = eventApi.useDeleteEventRegistrationMutation(); + + useEffect(() => { + dispatch(setEventId(id)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const { setRetriggerFetch, apiLoading, apiError } = useEvent(id as string); + useEffect(() => { + if (!userAttending) { + register.current?.close(); + } + }, [userAttending]); + + const handleRegistration = () => { + registerUser({ user_id: userId, event_id: id as string }).then( + ({ error }) => { + if (error) { + throw new Error(); + } else { + setUserAttending(true); + const eventPreview: EventPreview = { + id: event.id, + name: event.name, + host_name: clubName, + host_logo: clubLogo, + start_time: event.start_time, + end_time: event.end_time, + location: event.location, + event_type: event.event_type, + link: event.link + }; + dispatch(setUserRSVPs([...event.rsvps, eventPreview])); + } + } + ); + }; + + const handleUnregistration = () => { + unregisterUser({ user_id: userId, event_id: id as string }).then( + ({ error }) => { + if (error) { + console.log(error); + throw new Error(); + } else { + setUserAttending(false); + dispatch( + setUserRSVPs([ + ...rsvps.filter((event) => event.id !== id) + ]) + ); + } + } + ); + }; const headerAnimatedStyle = useAnimatedStyle(() => { return { @@ -93,19 +155,7 @@ const EventPage = () => { - ), - headerRight: !apiError - ? () => ( - - - shareEvent.current?.snapToIndex(0) - } - color="white" - /> - - ) - : () => <> + ) }} /> { register.current?.snapToIndex(0) } > - Register + {userAttending + ? 'Registered' + : 'Register'} { eventName={event?.name as string} location={event?.location as string} eventType={event?.event_type as EventType} + userAttending={userAttending} + onSubmit={ + userAttending ? handleUnregistration : handleRegistration + } ref={register} /> ( { return ; diff --git a/frontend/mobile/src/app/(app)/event/components/location.tsx b/frontend/mobile/src/app/app/event/components/location.tsx similarity index 92% rename from frontend/mobile/src/app/(app)/event/components/location.tsx rename to frontend/mobile/src/app/app/event/components/location.tsx index 779599655..473238879 100644 --- a/frontend/mobile/src/app/(app)/event/components/location.tsx +++ b/frontend/mobile/src/app/app/event/components/location.tsx @@ -1,8 +1,8 @@ import { Linking, Platform } from 'react-native'; import MapView, { Marker } from 'react-native-maps'; -import { Text } from '@/src/app/(design-system)'; -import { Box } from '@/src/app/(design-system)'; +import { Text } from '@/src/app/design-system'; +import { Box } from '@/src/app/design-system'; interface LocationProps { location: string; diff --git a/frontend/mobile/src/app/(app)/event/components/overview.tsx b/frontend/mobile/src/app/app/event/components/overview.tsx similarity index 92% rename from frontend/mobile/src/app/(app)/event/components/overview.tsx rename to frontend/mobile/src/app/app/event/components/overview.tsx index b26a8d40d..5a24246dd 100644 --- a/frontend/mobile/src/app/(app)/event/components/overview.tsx +++ b/frontend/mobile/src/app/app/event/components/overview.tsx @@ -9,12 +9,17 @@ import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; import { EventType } from '@generatesac/lib'; import { Avatar } from '@rneui/base'; -import { Box, Colors, SACColors, Text } from '@/src/app/(design-system)'; +import { Box, Colors, SACColors, Text } from '@/src/app/design-system'; import { setClubId, setClubShouldPreview } from '@/src/store/slices/clubSlice'; import { useAppDispatch } from '@/src/store/store'; -import { firstLetterUppercase } from '@/src/utils/string'; import { createOptions, eventTime } from '@/src/utils/time'; +const eventTypeMappings: Record = { + in_person: 'In Person', + virtual: 'Virtual', + hybrid: 'Hybrid' +}; + interface OverviewProps { logo: string; eventName: string; @@ -97,7 +102,7 @@ export const Overview: React.FC = ({ color={Colors[color]} icon={faGlobe} /> - {firstLetterUppercase(type)} + {eventTypeMappings[type]} diff --git a/frontend/mobile/src/app/(app)/event/components/register.tsx b/frontend/mobile/src/app/app/event/components/register.tsx similarity index 73% rename from frontend/mobile/src/app/(app)/event/components/register.tsx rename to frontend/mobile/src/app/app/event/components/register.tsx index ec3da04a0..ab186ea7a 100644 --- a/frontend/mobile/src/app/(app)/event/components/register.tsx +++ b/frontend/mobile/src/app/app/event/components/register.tsx @@ -7,9 +7,9 @@ import BottomSheet, { BottomSheetBackdrop } from '@gorhom/bottom-sheet'; import { zodResolver } from '@hookform/resolvers/zod'; import { ZodError, z } from 'zod'; -import { Box, Text } from '@/src/app/(design-system)'; -import { SelectOne } from '@/src/app/(design-system)'; -import { Button } from '@/src/app/(design-system)/components/Button/Button'; +import { Box, Text } from '@/src/app/design-system'; +import { SelectOne } from '@/src/app/design-system'; +import { Button } from '@/src/app/design-system/components/Button/Button'; import { CalendarLink } from '@/src/types/calendarLink'; import { Item } from '@/src/types/item'; import { createGoogleCalendarLink } from '@/src/utils/string'; @@ -18,7 +18,7 @@ import { Divider } from './divider'; type Ref = BottomSheet; -const SaveEventText = `By saving this event, you are automatically signed up for notifications and this event will be added to your calendar.`; +const SaveEventText = `Add the event to your calendar to make sure you are always up to date with your upcoming events!`; interface RegisterSheetProps { eventType: EventType; @@ -27,14 +27,27 @@ interface RegisterSheetProps { location: string; eventName: string; eventDetail: string; + userAttending: boolean; + onSubmit: () => void; } const RegisterBottomSheet = forwardRef( ( - { eventType, eventName, location, eventDetail, startTime, endTime }, + { + eventType, + eventName, + location, + eventDetail, + startTime, + endTime, + userAttending, + onSubmit + }, ref ) => { - const [sheet, setSheet] = useState('register'); + const [sheet, setSheet] = useState( + userAttending ? 'unregister' : 'register' + ); const renderBackdrop = useCallback( (props: any) => ( @@ -47,6 +60,20 @@ const RegisterBottomSheet = forwardRef( [] ); + const handleRegistrationNavAndSubmit = ( + submit: boolean, + nextSheet?: string + ) => { + try { + if (submit) { + onSubmit(); + } + if (nextSheet) { + setSheet(nextSheet); + } + } catch {} + }; + const CALENDAR_INFO: CalendarLink = { eventDetail: eventDetail, eventName: eventName, @@ -63,12 +90,23 @@ const RegisterBottomSheet = forwardRef( enablePanDownToClose backgroundStyle={{ backgroundColor: 'white' }} backdropComponent={renderBackdrop} + onClose={() => + setSheet(userAttending ? 'unregister' : 'register') + } > + {sheet === 'unregister' && } {sheet === 'register' && ( - + )} {sheet === 'attend' && ( - setSheet('save')} /> + + handleRegistrationNavAndSubmit(true, 'save') + } + /> )} {sheet === 'save' && ( ( } ); +interface UnregisterProps { + onClick: () => void; +} + +const Unregister: React.FC = ({ onClick }) => { + return ( + + + Unregister + + + + Are you sure you want to unregister from this event? + + + + + + ); +}; + interface RegisterProps { - setSheet: React.Dispatch>; + onClick: (submit: boolean, nextSheet?: string) => void; eventType: EventType; } @@ -98,7 +164,7 @@ const userInfoSchema = z.object({ select: userDataSchema }); -const Register: React.FC = ({ setSheet, eventType }) => { +const Register: React.FC = ({ onClick, eventType }) => { const { control, handleSubmit, @@ -111,11 +177,11 @@ const Register: React.FC = ({ setSheet, eventType }) => { const onSubmit = (data: UserData) => { try { if (data.select.key === 'maybe') { - setSheet('save'); + onClick(false, 'save'); } else if (data.select.key === 'yes' && eventType === 'hybrid') { - setSheet('attend'); + onClick(false, 'attend'); } else { - setSheet('save'); + onClick(true, 'save'); } } catch (error) { if (error instanceof ZodError) { @@ -189,7 +255,6 @@ const SaveEvent: React.FC = ({ googleCalendar }) => ( > Add to Google Calendar - ); diff --git a/frontend/mobile/src/app/(app)/event/components/share-event.tsx b/frontend/mobile/src/app/app/event/components/share-event.tsx similarity index 94% rename from frontend/mobile/src/app/(app)/event/components/share-event.tsx rename to frontend/mobile/src/app/app/event/components/share-event.tsx index 556c03525..d8716c2b4 100644 --- a/frontend/mobile/src/app/(app)/event/components/share-event.tsx +++ b/frontend/mobile/src/app/app/event/components/share-event.tsx @@ -3,8 +3,8 @@ import React, { forwardRef, useCallback, useState } from 'react'; import BottomSheet, { BottomSheetBackdrop } from '@gorhom/bottom-sheet'; import Clipboard from '@react-native-community/clipboard'; -import { Box, Text } from '@/src/app/(design-system)'; -import { Button } from '@/src/app/(design-system)/components/Button/Button'; +import { Box, Text } from '@/src/app/design-system'; +import { Button } from '@/src/app/design-system/components/Button/Button'; import { Divider } from './divider'; diff --git a/frontend/mobile/src/app/(app)/event/components/skeleton.tsx b/frontend/mobile/src/app/app/event/components/skeleton.tsx similarity index 96% rename from frontend/mobile/src/app/(app)/event/components/skeleton.tsx rename to frontend/mobile/src/app/app/event/components/skeleton.tsx index 5f16364f1..029b52e46 100644 --- a/frontend/mobile/src/app/(app)/event/components/skeleton.tsx +++ b/frontend/mobile/src/app/app/event/components/skeleton.tsx @@ -1,6 +1,6 @@ import { Skeleton } from '@rneui/base'; -import { Box, Colors } from '@/src/app/(design-system)'; +import { Box, Colors } from '@/src/app/design-system'; const EventPageSkeleton = () => { return ( diff --git a/frontend/mobile/src/app/(app)/event/components/upcoming-events.tsx b/frontend/mobile/src/app/app/event/components/upcoming-events.tsx similarity index 87% rename from frontend/mobile/src/app/(app)/event/components/upcoming-events.tsx rename to frontend/mobile/src/app/app/event/components/upcoming-events.tsx index aee95962a..02f7c1f8b 100644 --- a/frontend/mobile/src/app/(app)/event/components/upcoming-events.tsx +++ b/frontend/mobile/src/app/app/event/components/upcoming-events.tsx @@ -5,8 +5,8 @@ import { router } from 'expo-router'; import { Event } from '@generatesac/lib'; -import { Box, Text } from '@/src/app/(design-system)'; -import { EventCard } from '@/src/app/(design-system)/components/EventCard'; +import { Box, Text } from '@/src/app/design-system'; +import { EventCard } from '@/src/app/design-system/components/EventCard'; interface UpcomingEventsProps { events: Event[]; @@ -15,7 +15,7 @@ interface UpcomingEventsProps { export const UpcomingEvent: React.FC = ({ events }) => { const renderEventCard = ({ item }: { item: Event }) => { return ( - router.push(`/event/${item.id}`)}> + router.push(`/app/event/${item.id}`)}> { + return ( + + + + + + + ); +}; + +export default Layout; diff --git a/frontend/mobile/src/app/(app)/user/components/save.tsx b/frontend/mobile/src/app/app/user/components/save.tsx similarity index 88% rename from frontend/mobile/src/app/(app)/user/components/save.tsx rename to frontend/mobile/src/app/app/user/components/save.tsx index 8887906dc..f484d2d5a 100644 --- a/frontend/mobile/src/app/(app)/user/components/save.tsx +++ b/frontend/mobile/src/app/app/user/components/save.tsx @@ -1,6 +1,6 @@ import { TouchableOpacity } from 'react-native-gesture-handler'; -import { Text } from '@/src/app/(design-system)'; +import { Text } from '@/src/app/design-system'; interface SaveProps { onPress: () => void; diff --git a/frontend/mobile/src/app/(app)/user/detail.tsx b/frontend/mobile/src/app/app/user/detail.tsx similarity index 96% rename from frontend/mobile/src/app/(app)/user/detail.tsx rename to frontend/mobile/src/app/app/user/detail.tsx index 5e1fec190..638797ec8 100644 --- a/frontend/mobile/src/app/(app)/user/detail.tsx +++ b/frontend/mobile/src/app/app/user/detail.tsx @@ -7,11 +7,11 @@ import { Stack, router } from 'expo-router'; import { ZodError } from 'zod'; -import { Box, Spacing } from '@/src/app/(design-system)'; -import { MultiSelect, SelectOne } from '@/src/app/(design-system)'; -import { Text } from '@/src/app/(design-system)'; -import { Arrow } from '@/src/app/(design-system)'; -import { TextBox } from '@/src/app/(design-system)/components/Textbox/Textbox'; +import { Box, Spacing } from '@/src/app/design-system'; +import { MultiSelect, SelectOne } from '@/src/app/design-system'; +import { Text } from '@/src/app/design-system'; +import { Arrow } from '@/src/app/design-system'; +import { TextBox } from '@/src/app/design-system/components/Textbox/Textbox'; import { COLLEGE, GRADUATION_CYCLE, diff --git a/frontend/mobile/src/app/app/user/events.tsx b/frontend/mobile/src/app/app/user/events.tsx new file mode 100644 index 000000000..064689360 --- /dev/null +++ b/frontend/mobile/src/app/app/user/events.tsx @@ -0,0 +1,67 @@ +import { SafeAreaView } from 'react-native'; + +import { Stack, router } from 'expo-router'; + +import { useAppSelector } from '@/src/store/store'; + +import { Arrow, Box, Button, Text } from '../../design-system'; +import { EventCard } from '../../design-system/components/EventCard'; +import { GlobalLayout } from '../../design-system/components/GlobalLayout/GlobalLayout'; + +const UserEvents = () => { + const { rsvps } = useAppSelector((state) => state.user); + + return ( + <> + ( + Upcoming Events + ), + headerTransparent: true, + headerShown: true, + headerLeft: () => + }} + /> + + + + + {rsvps.map((event) => { + return ( + + ); + })} + + + + + + + + + ); +}; + +export default UserEvents; diff --git a/frontend/mobile/src/app/app/user/following.tsx b/frontend/mobile/src/app/app/user/following.tsx new file mode 100644 index 000000000..33e44fa7b --- /dev/null +++ b/frontend/mobile/src/app/app/user/following.tsx @@ -0,0 +1,89 @@ +import { useEffect } from 'react'; +import { SafeAreaView } from 'react-native'; + +import { Stack, router } from 'expo-router'; + +import { userApi } from '@generatesac/lib'; + +import { useAppSelector } from '@/src/store/store'; + +import { Arrow, Box, Button, Text } from '../../design-system'; +import { Clubcard } from '../../design-system/components/ClubCard/ClubCard'; +import ClubCardStandardSkeleton from '../../design-system/components/ClubCard/Skeletons/ClubCardStandardSkeleton'; +import { GlobalLayout } from '../../design-system/components/GlobalLayout/GlobalLayout'; +import PageError from '../../design-system/components/PageError/PageError'; + +const Following = () => { + const { id, following } = useAppSelector((state) => state.user); + const [getFollowers, { data, isLoading, error }] = + userApi.useLazyGetUserFollowingQuery(); + + useEffect(() => { + getFollowers(id); + }, [following, id, getFollowers]); + + if (error) { + return {}} />; + } + + return ( + <> + ( + Clubs you Follow + ), + headerTransparent: true, + headerShown: true, + headerLeft: () => + }} + /> + + + + {isLoading || !data ? ( + + + + + + ) : ( + + {data.map((club) => { + if (club.name !== 'SAC') { + return ( + + ); + } + })} + + + + + )} + + + + + ); +}; + +export default Following; diff --git a/frontend/mobile/src/app/(app)/user/interest.tsx b/frontend/mobile/src/app/app/user/interest.tsx similarity index 98% rename from frontend/mobile/src/app/(app)/user/interest.tsx rename to frontend/mobile/src/app/app/user/interest.tsx index 146a9324b..f3813d1e1 100644 --- a/frontend/mobile/src/app/(app)/user/interest.tsx +++ b/frontend/mobile/src/app/app/user/interest.tsx @@ -8,8 +8,8 @@ import { Stack, router } from 'expo-router'; import { Category, Tag, categoryApi, tagApi } from '@generatesac/lib'; import { ZodError } from 'zod'; -import { Arrow, Box, Spacing, Text } from '@/src/app/(design-system)'; -import { Tag as TagComponent } from '@/src/app/(design-system)'; +import { Arrow, Box, Spacing, Text } from '@/src/app/design-system'; +import { Tag as TagComponent } from '@/src/app/design-system'; import { formatCategoryOrTag } from '@/src/utils/string'; import { Save } from './components/save'; diff --git a/frontend/mobile/src/app/(auth)/_layout.tsx b/frontend/mobile/src/app/auth/_layout.tsx similarity index 62% rename from frontend/mobile/src/app/(auth)/_layout.tsx rename to frontend/mobile/src/app/auth/_layout.tsx index 303af5423..6c15e6a00 100644 --- a/frontend/mobile/src/app/(auth)/_layout.tsx +++ b/frontend/mobile/src/app/auth/_layout.tsx @@ -13,6 +13,14 @@ const Layout = () => { headerTransparent: true }} /> + ); }; diff --git a/frontend/mobile/src/app/auth/callback.tsx b/frontend/mobile/src/app/auth/callback.tsx new file mode 100644 index 000000000..d8651acd5 --- /dev/null +++ b/frontend/mobile/src/app/auth/callback.tsx @@ -0,0 +1,88 @@ +import { useEffect } from 'react'; +import { Image } from 'react-native'; + +import { router, useLocalSearchParams } from 'expo-router'; + +import { + Club, + OAuthCallbackRequestQueryParams, + authApi, + userApi +} from '@generatesac/lib'; + +import Loading from '@/src/assets/gif/loading.gif'; +import { setLoggedIn } from '@/src/store/slices/globalSlice'; +import { setUser, setUserFollowing } from '@/src/store/slices/userSlice'; +import { useAppDispatch, useAppSelector } from '@/src/store/store'; + +import { Box, Text } from '../design-system'; + +const OAuthCallback = () => { + const params = useLocalSearchParams(); + const [oAuthCallback] = authApi.useLazyCallbackQuery(); + const [getUserFollowing] = userApi.useLazyGetUserFollowingQuery(); + const dispatch = useAppDispatch(); + const { accessToken } = useAppSelector((state) => state.global); + + const parseUserFollowing = (clubs: Club[]) => { + return clubs + .filter((club) => club.name !== 'SAC') + .map((club) => club.id); + }; + + useEffect(() => { + const { code, session_state, state } = + params as OAuthCallbackRequestQueryParams; + if (code || session_state || state) { + oAuthCallback({ code, session_state, state }).then( + async ({ data, error }) => { + if (data) { + // Set the user: + dispatch(setUser(data)); + + // Retrieve their following: + await getUserFollowing(data.id).then( + ({ data: followingData }) => { + if (followingData) { + const following = + parseUserFollowing(followingData); + dispatch(setUserFollowing(following)); + } + } + ); + + // Set the user as logged in and redirect to the app: + dispatch(setLoggedIn(true)); + router.push('/app/'); + } + if (error) { + router.push('/auth/'); + } + } + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accessToken]); + + return ( + + + Logging you in... + + ); +}; + +export default OAuthCallback; diff --git a/frontend/mobile/src/app/(auth)/components/notification.tsx b/frontend/mobile/src/app/auth/components/notification.tsx similarity index 83% rename from frontend/mobile/src/app/(auth)/components/notification.tsx rename to frontend/mobile/src/app/auth/components/notification.tsx index fb238343b..76b1e8070 100644 --- a/frontend/mobile/src/app/(auth)/components/notification.tsx +++ b/frontend/mobile/src/app/auth/components/notification.tsx @@ -3,8 +3,8 @@ import { router } from 'expo-router'; import { faBell } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { Box, Text } from '../../(design-system)'; -import { Button } from '../../(design-system)/components/Button/Button'; +import { Box, Text } from '../../design-system'; +import { Button } from '../../design-system/components/Button/Button'; export const Notification = () => { return ( @@ -21,7 +21,7 @@ export const Notification = () => { diff --git a/frontend/mobile/src/app/(auth)/components/small-text.tsx b/frontend/mobile/src/app/auth/components/small-text.tsx similarity index 94% rename from frontend/mobile/src/app/(auth)/components/small-text.tsx rename to frontend/mobile/src/app/auth/components/small-text.tsx index 27ea47971..d592c173f 100644 --- a/frontend/mobile/src/app/(auth)/components/small-text.tsx +++ b/frontend/mobile/src/app/auth/components/small-text.tsx @@ -1,6 +1,6 @@ import { TouchableOpacity } from 'react-native-gesture-handler'; -import { Box, Text } from '../../(design-system)'; +import { Box, Text } from '../../design-system'; interface SmallTextProps { first: string; diff --git a/frontend/mobile/src/app/(auth)/components/splash-screen.tsx b/frontend/mobile/src/app/auth/components/splash-screen.tsx similarity index 95% rename from frontend/mobile/src/app/(auth)/components/splash-screen.tsx rename to frontend/mobile/src/app/auth/components/splash-screen.tsx index b7298d330..52d3e0751 100644 --- a/frontend/mobile/src/app/(auth)/components/splash-screen.tsx +++ b/frontend/mobile/src/app/auth/components/splash-screen.tsx @@ -1,7 +1,7 @@ import { IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { Box, Text } from '../../(design-system)'; +import { Box, Text } from '../../design-system'; export interface ScreenProps { title: string; diff --git a/frontend/mobile/src/app/auth/index.tsx b/frontend/mobile/src/app/auth/index.tsx new file mode 100644 index 000000000..55c60a15c --- /dev/null +++ b/frontend/mobile/src/app/auth/index.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Image, Linking, SafeAreaView } from 'react-native'; + +import { authApi } from '@generatesac/lib'; + +import Loading from '@/src/assets/gif/loading.gif'; +import { setAccessToken } from '@/src/store/slices/globalSlice'; +import { useAppDispatch } from '@/src/store/store'; + +import { Box, Button, Text } from '../design-system'; +import { GlobalLayout } from '../design-system/components/GlobalLayout/GlobalLayout'; + +const WelcomePage = () => { + const [login, { isLoading }] = authApi.useLazyLoginQuery(); + const dispatch = useAppDispatch(); + + const handleLogin = () => { + login().then(async ({ data }) => { + if (data) { + console.log(data.sac_session); + await Linking.openURL(data.redirect_uri); + dispatch(setAccessToken(data.sac_session)); + } + }); + }; + + return ( + + + + + Welcome to the + + Student Activity Calendar + + + + + + + ); +}; + +export default WelcomePage; diff --git a/frontend/mobile/src/app/(design-system)/assets/fonts/DMSans-Bold.ttf b/frontend/mobile/src/app/design-system/assets/fonts/DMSans-Bold.ttf similarity index 100% rename from frontend/mobile/src/app/(design-system)/assets/fonts/DMSans-Bold.ttf rename to frontend/mobile/src/app/design-system/assets/fonts/DMSans-Bold.ttf diff --git a/frontend/mobile/src/app/(design-system)/assets/fonts/DMSans-Medium.ttf b/frontend/mobile/src/app/design-system/assets/fonts/DMSans-Medium.ttf similarity index 100% rename from frontend/mobile/src/app/(design-system)/assets/fonts/DMSans-Medium.ttf rename to frontend/mobile/src/app/design-system/assets/fonts/DMSans-Medium.ttf diff --git a/frontend/mobile/src/app/(design-system)/assets/fonts/DMSans-Regular.ttf b/frontend/mobile/src/app/design-system/assets/fonts/DMSans-Regular.ttf similarity index 100% rename from frontend/mobile/src/app/(design-system)/assets/fonts/DMSans-Regular.ttf rename to frontend/mobile/src/app/design-system/assets/fonts/DMSans-Regular.ttf diff --git a/frontend/mobile/src/app/(design-system)/assets/svg/Error.svg b/frontend/mobile/src/app/design-system/assets/svg/Error.svg similarity index 100% rename from frontend/mobile/src/app/(design-system)/assets/svg/Error.svg rename to frontend/mobile/src/app/design-system/assets/svg/Error.svg diff --git a/frontend/mobile/src/app/(design-system)/components/AboutSection/AboutSection.tsx b/frontend/mobile/src/app/design-system/components/AboutSection/AboutSection.tsx similarity index 100% rename from frontend/mobile/src/app/(design-system)/components/AboutSection/AboutSection.tsx rename to frontend/mobile/src/app/design-system/components/AboutSection/AboutSection.tsx diff --git a/frontend/mobile/src/app/(design-system)/components/AnimatedImageHeader/AnimatedImageHeader.tsx b/frontend/mobile/src/app/design-system/components/AnimatedImageHeader/AnimatedImageHeader.tsx similarity index 100% rename from frontend/mobile/src/app/(design-system)/components/AnimatedImageHeader/AnimatedImageHeader.tsx rename to frontend/mobile/src/app/design-system/components/AnimatedImageHeader/AnimatedImageHeader.tsx diff --git a/frontend/mobile/src/app/(design-system)/components/Arrow/Arrow.tsx b/frontend/mobile/src/app/design-system/components/Arrow/Arrow.tsx similarity index 100% rename from frontend/mobile/src/app/(design-system)/components/Arrow/Arrow.tsx rename to frontend/mobile/src/app/design-system/components/Arrow/Arrow.tsx diff --git a/frontend/mobile/src/app/(design-system)/components/Box/Box.tsx b/frontend/mobile/src/app/design-system/components/Box/Box.tsx similarity index 100% rename from frontend/mobile/src/app/(design-system)/components/Box/Box.tsx rename to frontend/mobile/src/app/design-system/components/Box/Box.tsx diff --git a/frontend/mobile/src/app/(design-system)/components/Button/Button.tsx b/frontend/mobile/src/app/design-system/components/Button/Button.tsx similarity index 95% rename from frontend/mobile/src/app/(design-system)/components/Button/Button.tsx rename to frontend/mobile/src/app/design-system/components/Button/Button.tsx index 3dcce098a..6b97d61ae 100644 --- a/frontend/mobile/src/app/(design-system)/components/Button/Button.tsx +++ b/frontend/mobile/src/app/design-system/components/Button/Button.tsx @@ -4,13 +4,13 @@ import { GestureResponderEvent, TouchableOpacity } from 'react-native'; import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; import { BoxProps, createBox } from '@shopify/restyle'; -import { Text } from '@/src/app/(design-system)/components/Text/Text'; +import { Text } from '@/src/app/design-system/components/Text/Text'; import { SACColors, defaultColor, textColorVariants -} from '@/src/app/(design-system)/shared/colors'; -import { Theme, createStyles } from '@/src/app/(design-system)/theme'; +} from '@/src/app/design-system/shared/colors'; +import { Theme, createStyles } from '@/src/app/design-system/theme'; import { ComponentSizes } from '../../shared/types'; import { Icon } from '../Icon/Icon'; diff --git a/frontend/mobile/src/app/(design-system)/components/Calendar/Calendar.tsx b/frontend/mobile/src/app/design-system/components/Calendar/Calendar.tsx similarity index 98% rename from frontend/mobile/src/app/(design-system)/components/Calendar/Calendar.tsx rename to frontend/mobile/src/app/design-system/components/Calendar/Calendar.tsx index 5b4a9ab78..aeb46f487 100644 --- a/frontend/mobile/src/app/(design-system)/components/Calendar/Calendar.tsx +++ b/frontend/mobile/src/app/design-system/components/Calendar/Calendar.tsx @@ -10,11 +10,11 @@ import { State } from 'react-native-gesture-handler'; -import { Box } from '@/src/app/(design-system)/components/Box/Box'; -import Day from '@/src/app/(design-system)/components/Calendar/Day'; -import { EventSection } from '@/src/app/(design-system)/components/Calendar/DayTimeSection'; -import { EventCardCalendarSkeleton } from '@/src/app/(design-system)/components/EventCard/Skeletons/EventCardCalendarSkeleton'; -import { Spacing } from '@/src/app/(design-system)/shared/spacing'; +import { Box } from '@/src/app/design-system/components/Box/Box'; +import Day from '@/src/app/design-system/components/Calendar/Day'; +import { EventSection } from '@/src/app/design-system/components/Calendar/DayTimeSection'; +import { EventCardCalendarSkeleton } from '@/src/app/design-system/components/EventCard/Skeletons/EventCardCalendarSkeleton'; +import { Spacing } from '@/src/app/design-system/shared/spacing'; type SwipeDirection = 'left' | 'right'; diff --git a/frontend/mobile/src/app/(design-system)/components/Calendar/Day.tsx b/frontend/mobile/src/app/design-system/components/Calendar/Day.tsx similarity index 89% rename from frontend/mobile/src/app/(design-system)/components/Calendar/Day.tsx rename to frontend/mobile/src/app/design-system/components/Calendar/Day.tsx index 7629007ae..7bd95d888 100644 --- a/frontend/mobile/src/app/(design-system)/components/Calendar/Day.tsx +++ b/frontend/mobile/src/app/design-system/components/Calendar/Day.tsx @@ -1,8 +1,8 @@ -import { Box } from '@/src/app/(design-system)/components/Box/Box'; +import { Box } from '@/src/app/design-system/components/Box/Box'; import DayTimeSection, { EventSection -} from '@/src/app/(design-system)/components/Calendar/DayTimeSection'; -import { Text } from '@/src/app/(design-system)/components/Text/Text'; +} from '@/src/app/design-system/components/Calendar/DayTimeSection'; +import { Text } from '@/src/app/design-system/components/Text/Text'; import NoEventsIcon from '@/src/assets/svg/NoSearchResult.svg'; import { formatTime } from '@/src/utils/time'; diff --git a/frontend/mobile/src/app/(design-system)/components/Calendar/DayTimeSection.tsx b/frontend/mobile/src/app/design-system/components/Calendar/DayTimeSection.tsx similarity index 87% rename from frontend/mobile/src/app/(design-system)/components/Calendar/DayTimeSection.tsx rename to frontend/mobile/src/app/design-system/components/Calendar/DayTimeSection.tsx index 861e7e10d..e824fb9b5 100644 --- a/frontend/mobile/src/app/(design-system)/components/Calendar/DayTimeSection.tsx +++ b/frontend/mobile/src/app/design-system/components/Calendar/DayTimeSection.tsx @@ -1,9 +1,9 @@ import { Tag } from '@generatesac/lib'; -import { Box } from '@/src/app/(design-system)/components/Box/Box'; -import { Button } from '@/src/app/(design-system)/components/Button/Button'; -import { EventCard } from '@/src/app/(design-system)/components/EventCard/EventCard'; -import { Text } from '@/src/app/(design-system)/components/Text/Text'; +import { Box } from '@/src/app/design-system/components/Box/Box'; +import { Button } from '@/src/app/design-system/components/Button/Button'; +import { EventCard } from '@/src/app/design-system/components/EventCard/EventCard'; +import { Text } from '@/src/app/design-system/components/Text/Text'; export type EventSectionTimes = { [key: string]: EventSectionData; @@ -40,17 +40,15 @@ function convertNumToTime(num: number) { let meridiem = num === 23 ? 'AM' : 'PM'; let minutes = num % 100; - if (num !== 0 && (num < 1000 || num > 2359)) { + if (num !== 0 && (num < 0 || num > 2359)) { throw new Error('Invalid time'); } time = time / 100; - - if (num < 12) { - time += 12; + if (time < 12) { meridiem = 'AM'; } - if (num > 12) { + if (time > 12) { time -= 12; } diff --git a/frontend/mobile/src/app/(design-system)/components/Calendar/parser/calendarParser.ts b/frontend/mobile/src/app/design-system/components/Calendar/parser/calendarParser.ts similarity index 96% rename from frontend/mobile/src/app/(design-system)/components/Calendar/parser/calendarParser.ts rename to frontend/mobile/src/app/design-system/components/Calendar/parser/calendarParser.ts index 60d1e96b3..0a9626804 100644 --- a/frontend/mobile/src/app/(design-system)/components/Calendar/parser/calendarParser.ts +++ b/frontend/mobile/src/app/design-system/components/Calendar/parser/calendarParser.ts @@ -1,10 +1,10 @@ import { EventPreview } from '@generatesac/lib'; -import { DAY_EPOCH_TIME } from '@/src/app/(design-system)/components/Calendar/Calendar'; +import { DAY_EPOCH_TIME } from '@/src/app/design-system/components/Calendar/Calendar'; import { EventCalendarPreview, EventSection -} from '@/src/app/(design-system)/components/Calendar/DayTimeSection'; +} from '@/src/app/design-system/components/Calendar/DayTimeSection'; const eventPreviewImages = [ 'https://storage.googleapis.com/pod_public/1300/165117.jpg', diff --git a/frontend/mobile/src/app/design-system/components/ClubCard/ClubCard.tsx b/frontend/mobile/src/app/design-system/components/ClubCard/ClubCard.tsx new file mode 100644 index 000000000..5fa309f1c --- /dev/null +++ b/frontend/mobile/src/app/design-system/components/ClubCard/ClubCard.tsx @@ -0,0 +1,25 @@ +import { Tag } from '@generatesac/lib'; + +import ClubCardStandard from './Variants/ClubCardStandard'; + +export interface ClubCardProps { + id: string; + logo: string; + name: string; + tags?: Tag[]; + variant?: 'standard'; +} + +export const Clubcard = ({ + id, + logo, + name, + variant = 'standard' +}: ClubCardProps) => { + switch (variant) { + case 'standard': + return ; + default: + return ; + } +}; diff --git a/frontend/mobile/src/app/design-system/components/ClubCard/Skeletons/ClubCardStandardSkeleton.tsx b/frontend/mobile/src/app/design-system/components/ClubCard/Skeletons/ClubCardStandardSkeleton.tsx new file mode 100644 index 000000000..003e4447d --- /dev/null +++ b/frontend/mobile/src/app/design-system/components/ClubCard/Skeletons/ClubCardStandardSkeleton.tsx @@ -0,0 +1,48 @@ +import { Skeleton } from '@rneui/base'; + +import { Colors } from '../../../shared/colors'; +import { createStyles } from '../../../theme'; +import { Box } from '../../Box/Box'; + +const ClubCardStandardSkeleton = () => { + return ( + + + + + + + ); +}; + +const styles = createStyles({ + cardContainer: { + shadowColor: 'black', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + backgroundColor: 'white', + borderRadius: 'md' + }, + cardSubContainer: { + width: '100%', + borderRadius: 'md', + padding: 'm', + backgroundColor: 'white', + flexDirection: 'row', + alignItems: 'center', + gap: 's', + overflow: 'hidden' + } +}); + +export default ClubCardStandardSkeleton; diff --git a/frontend/mobile/src/app/design-system/components/ClubCard/Variants/ClubCardStandard.tsx b/frontend/mobile/src/app/design-system/components/ClubCard/Variants/ClubCardStandard.tsx new file mode 100644 index 000000000..6e5000969 --- /dev/null +++ b/frontend/mobile/src/app/design-system/components/ClubCard/Variants/ClubCardStandard.tsx @@ -0,0 +1,59 @@ +import { TouchableOpacity } from 'react-native-gesture-handler'; + +import { router } from 'expo-router'; + +import { resetEvent } from '@/src/store/slices/eventSlice'; +import { useAppDispatch } from '@/src/store/store'; + +import { createStyles } from '../../../theme'; +import { Box } from '../../Box/Box'; +import { ClubIcon } from '../../ClubIcon/ClubIcon'; +import { Text } from '../../Text/Text'; +import { ClubCardProps } from '../ClubCard'; + +const ClubCardStandard = ({ logo, name, id }: ClubCardProps) => { + const dispatch = useAppDispatch(); + + return ( + + { + dispatch(resetEvent()); + router.navigate(`/app/club/${id}`); + }} + > + + + + {name.length > 28 + ? name.slice(0, 28).trim() + '...' + : name} + + + + + ); +}; + +const styles = createStyles({ + cardContainer: { + shadowColor: 'black', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + backgroundColor: 'white', + borderRadius: 'md', + width: '100%' + }, + cardSubContainer: { + width: '100%', + borderRadius: 'md', + padding: 'm', + backgroundColor: 'white', + flexDirection: 'row', + alignItems: 'center', + gap: 's' + } +}); + +export default ClubCardStandard; diff --git a/frontend/mobile/src/app/(design-system)/components/ClubIcon/ClubIcon.tsx b/frontend/mobile/src/app/design-system/components/ClubIcon/ClubIcon.tsx similarity index 77% rename from frontend/mobile/src/app/(design-system)/components/ClubIcon/ClubIcon.tsx rename to frontend/mobile/src/app/design-system/components/ClubIcon/ClubIcon.tsx index 5a9280cb0..fa90f8703 100644 --- a/frontend/mobile/src/app/(design-system)/components/ClubIcon/ClubIcon.tsx +++ b/frontend/mobile/src/app/design-system/components/ClubIcon/ClubIcon.tsx @@ -4,12 +4,13 @@ import { Avatar } from '@rneui/themed'; interface ClubIconProps { imageUrl: string; + size?: number; } -export const ClubIcon: React.FC = ({ imageUrl }) => { +export const ClubIcon: React.FC = ({ imageUrl, size = 77 }) => { return ( = ({ }) => { const renderEventCard = ({ item }: { item: Event }) => { return ( - router.push(`/event/${item.id}`)}> + router.push(`/app/event/${item.id}`)}> { return ( diff --git a/frontend/mobile/src/app/(design-system)/components/EventCard/Variants/EventCardBig.tsx b/frontend/mobile/src/app/design-system/components/EventCard/Variants/EventCardBig.tsx similarity index 94% rename from frontend/mobile/src/app/(design-system)/components/EventCard/Variants/EventCardBig.tsx rename to frontend/mobile/src/app/design-system/components/EventCard/Variants/EventCardBig.tsx index 1edd303d7..31bd582ac 100644 --- a/frontend/mobile/src/app/(design-system)/components/EventCard/Variants/EventCardBig.tsx +++ b/frontend/mobile/src/app/design-system/components/EventCard/Variants/EventCardBig.tsx @@ -6,7 +6,7 @@ import { router } from 'expo-router'; import { Image } from '@rneui/base'; -import { Box, Text } from '@/src/app/(design-system)'; +import { Box, Text } from '@/src/app/design-system'; import { createOptions, eventTime } from '@/src/utils/time'; interface EventCardBigProps { @@ -29,7 +29,7 @@ export const EventCardBig: React.FC = ({ return ( router.navigate(`/event/${eventId}`)} + onPress={() => router.navigate(`/app/event/${eventId}`)} > = ({ return ( router.navigate(`/event/${eventId}`)} + onPress={() => router.navigate(`app/event/${eventId}`)} > diff --git a/frontend/mobile/src/app/(design-system)/components/EventCard/Variants/EventCardSmall.tsx b/frontend/mobile/src/app/design-system/components/EventCard/Variants/EventCardSmall.tsx similarity index 93% rename from frontend/mobile/src/app/(design-system)/components/EventCard/Variants/EventCardSmall.tsx rename to frontend/mobile/src/app/design-system/components/EventCard/Variants/EventCardSmall.tsx index 740e71105..9240e73dd 100644 --- a/frontend/mobile/src/app/(design-system)/components/EventCard/Variants/EventCardSmall.tsx +++ b/frontend/mobile/src/app/design-system/components/EventCard/Variants/EventCardSmall.tsx @@ -6,7 +6,7 @@ import { router } from 'expo-router'; import { Image } from '@rneui/base'; -import { Box, Text } from '@/src/app/(design-system)'; +import { Box, Text } from '@/src/app/design-system'; import { createOptions, eventTime } from '@/src/utils/time'; interface EventCardSmallProps { @@ -32,7 +32,7 @@ export const EventCardSmall: React.FC = ({ return ( router.navigate(`/event/${eventId}`)} + onPress={() => router.navigate(`app/event/${eventId}`)} > { - return ( - - {children} - - ); + return {children}; }; diff --git a/frontend/mobile/src/app/(design-system)/components/Icon/Icon.tsx b/frontend/mobile/src/app/design-system/components/Icon/Icon.tsx similarity index 100% rename from frontend/mobile/src/app/(design-system)/components/Icon/Icon.tsx rename to frontend/mobile/src/app/design-system/components/Icon/Icon.tsx diff --git a/frontend/mobile/src/app/(design-system)/components/Kebab/Kebab.tsx b/frontend/mobile/src/app/design-system/components/Kebab/Kebab.tsx similarity index 100% rename from frontend/mobile/src/app/(design-system)/components/Kebab/Kebab.tsx rename to frontend/mobile/src/app/design-system/components/Kebab/Kebab.tsx diff --git a/frontend/mobile/src/app/(design-system)/components/PageError/PageError.tsx b/frontend/mobile/src/app/design-system/components/PageError/PageError.tsx similarity index 92% rename from frontend/mobile/src/app/(design-system)/components/PageError/PageError.tsx rename to frontend/mobile/src/app/design-system/components/PageError/PageError.tsx index 5fcb1ef28..1d444598f 100644 --- a/frontend/mobile/src/app/(design-system)/components/PageError/PageError.tsx +++ b/frontend/mobile/src/app/design-system/components/PageError/PageError.tsx @@ -2,7 +2,7 @@ import { SafeAreaView } from 'react-native'; import { faArrowsRotate } from '@fortawesome/free-solid-svg-icons'; -import { Box, Button, Spacing, Text } from '@/src/app/(design-system)'; +import { Box, Button, Spacing, Text } from '@/src/app/design-system'; type EventPageErrorProps = { refetch: React.Dispatch>; diff --git a/frontend/mobile/src/app/(design-system)/components/PointOfContactCard/PointOfContactCard.tsx b/frontend/mobile/src/app/design-system/components/PointOfContactCard/PointOfContactCard.tsx similarity index 100% rename from frontend/mobile/src/app/(design-system)/components/PointOfContactCard/PointOfContactCard.tsx rename to frontend/mobile/src/app/design-system/components/PointOfContactCard/PointOfContactCard.tsx diff --git a/frontend/mobile/src/app/(design-system)/components/PointOfContactCard/PointofContactsList.tsx b/frontend/mobile/src/app/design-system/components/PointOfContactCard/PointofContactsList.tsx similarity index 93% rename from frontend/mobile/src/app/(design-system)/components/PointOfContactCard/PointofContactsList.tsx rename to frontend/mobile/src/app/design-system/components/PointOfContactCard/PointofContactsList.tsx index 7d7b0394f..aa177908f 100644 --- a/frontend/mobile/src/app/(design-system)/components/PointOfContactCard/PointofContactsList.tsx +++ b/frontend/mobile/src/app/design-system/components/PointOfContactCard/PointofContactsList.tsx @@ -19,7 +19,7 @@ export const PointOfContactList: React.FC = ({ }) => { const renderPointOfContact = ({ item }: { item: PointOfContact }) => { return ( - router.push(`/contact/${item.id}`)}> + router.push(`/app/contact/${item.id}`)}> (