all repos

onasty @ 164a37ba21c3c0c386d8d388b5e54844e3991f62

a one-time notes service

onasty/web/src/Pages/Profile.elm (view raw)

Olexandr Smirnov Olexandr Smirnov
ss2316544@gmail.com
feat(web): add account settings (#190)..., 9 months ago
1
module Pages.Profile exposing (Model, Msg, ViewVariant, page)
2
3
import Api
4
import Api.Profile
5
import Auth
6
import Components.Box
7
import Components.Form
8
import Components.Utils
9
import Data.Me exposing (Me)
10
import Effect exposing (Effect)
11
import Html as H exposing (Html)
12
import Html.Attributes as A
13
import Html.Events
14
import Layouts
15
import Page exposing (Page)
16
import Route exposing (Route)
17
import Shared
18
import Time.Format
19
import Validators
20
import View exposing (View)
21
22
23
page : Auth.User -> Shared.Model -> Route () -> Page Model Msg
24
page _ shared _ =
25
    Page.new
26
        { init = init shared
27
        , update = update
28
        , subscriptions = subscriptions
29
        , view = view shared
30
        }
31
        |> Page.withLayout (\_ -> Layouts.Header {})
32
33
34
35
-- INIT
36
37
38
type alias Model =
39
    { view : ViewVariant
40
    , me : Api.Response Me
41
    , password : { current : String, new : String, confirm : String }
42
    , email : String
43
    , apiError : Maybe Api.Error
44
    , isFormSentSuccessfully : Bool
45
    }
46
47
48
init : Shared.Model -> () -> ( Model, Effect Msg )
49
init _ () =
50
    ( { view = Overview
51
      , me = Api.Loading
52
      , password = { current = "", new = "", confirm = "" }
53
      , email = ""
54
      , apiError = Nothing
55
      , isFormSentSuccessfully = False
56
      }
57
    , Api.Profile.me { onResponse = ApiMeResponded }
58
    )
59
60
61
62
-- UPDATE
63
64
65
type ViewVariant
66
    = Overview
67
    | Password
68
    | Email
69
70
71
type Field
72
    = PasswordCurrent
73
    | PasswordNew
74
    | PasswordConfirm
75
    | EmailNew
76
77
78
type Msg
79
    = UserChangedView ViewVariant
80
    | UserClickedSubmit
81
    | UserChangedField Field String
82
    | ApiMeResponded (Result Api.Error Me)
83
    | ApiChangePasswordResponsed (Result Api.Error ())
84
    | ApiRequestEmailChangeResponsed (Result Api.Error ())
85
86
87
update : Msg -> Model -> ( Model, Effect Msg )
88
update msg model =
89
    case msg of
90
        UserChangedView variant ->
91
            ( { model | view = variant, isFormSentSuccessfully = False, apiError = Nothing }, Effect.none )
92
93
        UserChangedField PasswordCurrent value ->
94
            ( { model | password = { current = value, new = model.password.new, confirm = model.password.confirm } }, Effect.none )
95
96
        UserChangedField PasswordNew value ->
97
            ( { model | password = { current = model.password.current, new = value, confirm = model.password.confirm } }, Effect.none )
98
99
        UserChangedField PasswordConfirm value ->
100
            ( { model | password = { current = model.password.current, new = model.password.new, confirm = value } }, Effect.none )
101
102
        UserChangedField EmailNew value ->
103
            ( { model | email = value }, Effect.none )
104
105
        UserClickedSubmit ->
106
            case model.view of
107
                Password ->
108
                    ( model
109
                    , Api.Profile.changePassword
110
                        { onResponse = ApiChangePasswordResponsed
111
                        , currentPassword = model.password.current
112
                        , newPassword = model.password.new
113
                        }
114
                    )
115
116
                Email ->
117
                    ( model
118
                    , Api.Profile.requestEmailChange
119
                        { onResponse = ApiRequestEmailChangeResponsed
120
                        , newEmail = model.email
121
                        }
122
                    )
123
124
                _ ->
125
                    ( model, Effect.none )
126
127
        ApiMeResponded (Ok userData) ->
128
            ( { model | me = Api.Success userData }, Effect.none )
129
130
        ApiMeResponded (Err error) ->
131
            ( { model | me = Api.Failure error }, Effect.none )
132
133
        ApiChangePasswordResponsed (Ok ()) ->
134
            ( { model | isFormSentSuccessfully = True }, Effect.none )
135
136
        ApiChangePasswordResponsed (Err err) ->
137
            ( { model | apiError = Just err }, Effect.none )
138
139
        ApiRequestEmailChangeResponsed (Ok ()) ->
140
            ( { model | isFormSentSuccessfully = True }, Effect.none )
141
142
        ApiRequestEmailChangeResponsed (Err err) ->
143
            ( { model | apiError = Just err }, Effect.none )
144
145
146
subscriptions : Model -> Sub Msg
147
subscriptions _ =
148
    Sub.none
149
150
151
152
-- VIEW
153
154
155
view : Shared.Model -> Model -> View Msg
156
view shared model =
157
    { title = "Profile"
158
    , body =
159
        [ H.div [ A.class "w-full p-6 max-w-4xl mx-auto" ]
160
            [ H.div [ A.class "rounded-lg border border-gray-200 shadow-sm" ]
161
                [ H.div [ A.class "p-6 border-b border-gray-200" ]
162
                    [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e))
163
                    , H.h1 [ A.class "text-2xl font-bold text-gray-900" ] [ H.text "Account Settings" ]
164
                    , H.p [ A.class "text-gray-600" ] [ H.text "Manage your account preferences and security settings" ]
165
                    ]
166
                , H.div [ A.class "flex" ]
167
                    [ viewNavigationSidebar model
168
                    , H.div [ A.class "flex-1 p-6" ]
169
                        [ case model.me of
170
                            Api.Success me ->
171
                                case model.view of
172
                                    Overview ->
173
                                        viewOverview shared me
174
175
                                    Password ->
176
                                        viewPassword model.password (isFormDisabled model) model.isFormSentSuccessfully
177
178
                                    Email ->
179
                                        viewEmail me model.email (isFormDisabled model) model.isFormSentSuccessfully
180
181
                            Api.Loading ->
182
                                H.text "Loading..."
183
184
                            Api.Failure err ->
185
                                H.text ("ERROR: " ++ Api.errorMessage err)
186
                        ]
187
                    ]
188
                ]
189
            ]
190
        ]
191
    }
192
193
194
isFormDisabled : Model -> Bool
195
isFormDisabled model =
196
    case model.view of
197
        Overview ->
198
            True
199
200
        Password ->
201
            (Validators.password model.password.new /= Nothing)
202
                || (Validators.passwords model.password.new model.password.confirm /= Nothing)
203
204
        Email ->
205
            Validators.email model.email /= Nothing
206
207
208
viewNavigationSidebar : Model -> Html Msg
209
viewNavigationSidebar model =
210
    let
211
        button variant text =
212
            Components.Form.button
213
                { text = text
214
                , onClick = UserChangedView variant
215
                , disabled = model.view == variant
216
                , style = Components.Form.PrimaryReverse (model.view == variant)
217
                }
218
    in
219
    H.div [ A.class "w-64 border-r border-gray-200 p-6" ]
220
        [ H.nav [ A.class "[&>*]:w-full space-y-2" ]
221
            [ button Overview "Overview"
222
            , button Password "Password"
223
            , button Email "Email"
224
            ]
225
        ]
226
227
228
viewOverview : Shared.Model -> Me -> Html Msg
229
viewOverview shared me =
230
    let
231
        infoBox title text =
232
            H.div [ A.class "bg-gray-50 rounded-lg p-4" ]
233
                [ H.div [ A.class "flex items-center gap-3 mb-2" ]
234
                    [ H.h3 [ A.class "font-medium text-gray-900" ] [ H.text title ] ]
235
                , H.p [ A.class "text-gray-700" ] [ H.text text ]
236
                ]
237
    in
238
    viewWrapper
239
        { title = "Account Overview"
240
        , body =
241
            H.div [ A.class "grid grid-cols-1 md:grid-cols-2 gap-6" ]
242
                [ infoBox "Email Address" me.email
243
                , infoBox "Member Since" (Time.Format.toString shared.timeZone me.createdAt)
244
                , infoBox "Last Login" (Time.Format.toString shared.timeZone me.lastLoginAt)
245
                , infoBox "Total Notes Created" (String.fromInt me.notesCreated)
246
                ]
247
        }
248
249
250
viewPassword : { current : String, new : String, confirm : String } -> Bool -> Bool -> Html Msg
251
viewPassword password isButtonDisabled isFormSentSuccessfully =
252
    let
253
        input : { label : String, field : Field, value : String, error : Maybe String } -> Html Msg
254
        input { label, field, value, error } =
255
            Components.Form.input
256
                { label = label
257
                , id = label
258
                , field = field
259
                , onInput = UserChangedField field
260
                , placeholder = ""
261
                , value = value
262
                , required = True
263
                , type_ = "password"
264
                , style = Components.Form.Simple
265
                , error = error
266
                }
267
    in
268
    viewWrapper
269
        { title = "Change Password"
270
        , body =
271
            H.form
272
                [ A.class "space-y-4 max-w-md"
273
                , Html.Events.onSubmit UserClickedSubmit
274
                ]
275
                [ Components.Utils.viewIf isFormSentSuccessfully (Components.Box.successText "Password updated successfully!")
276
                , input { label = "Current Password", field = PasswordCurrent, value = password.current, error = Nothing }
277
                , input { label = "New Password", field = PasswordNew, value = password.new, error = Validators.password password.new }
278
                , input { label = "Confirm New Password", field = PasswordConfirm, value = password.confirm, error = Validators.passwords password.new password.confirm }
279
                , Components.Form.submitButton
280
                    { disabled = isButtonDisabled
281
                    , text = "Change Password"
282
                    , style = Components.Form.Primary isButtonDisabled
283
                    , class = ""
284
                    }
285
                ]
286
        }
287
288
289
viewEmail : Me -> String -> Bool -> Bool -> Html Msg
290
viewEmail me email isButtonDisabled isFormSentSuccessfully =
291
    viewWrapper
292
        { title = "Change Email Address"
293
        , body =
294
            H.form
295
                [ A.class "space-y-4 max-w-md"
296
                , Html.Events.onSubmit UserClickedSubmit
297
                ]
298
                [ H.div [ A.class "mb-6 p-4 bg-blue-50 border border-blue-200 rounded-md" ]
299
                    [ H.h3 [ A.class "font-medium mb-1" ] [ H.text "Note:" ]
300
                    , H.p [] [ H.text "A confirmation email will be sent to your current email address. You must confirm the change by clicking the link in that email." ]
301
                    , H.p [ A.class "mt-2 text-blue-800 text-sm" ]
302
                        [ H.span [ A.class "font-medium" ] [ H.text ("Current email: " ++ me.email) ]
303
                        ]
304
                    ]
305
                , Components.Utils.viewIf isFormSentSuccessfully (Components.Box.successText "Email updated successfully! Please check your new email for verification.")
306
                , Components.Form.input
307
                    { style = Components.Form.Simple
308
                    , id = "new-email"
309
                    , type_ = "email"
310
                    , field = EmailNew
311
                    , label = "New Email Address"
312
                    , value = email
313
                    , placeholder = "Enter your new email address"
314
                    , onInput = UserChangedField EmailNew
315
                    , error = Validators.email email
316
                    , required = True
317
                    }
318
                , Components.Form.submitButton
319
                    { disabled = isButtonDisabled
320
                    , text = "Update Email"
321
                    , style = Components.Form.Primary isButtonDisabled
322
                    , class = ""
323
                    }
324
                ]
325
        }
326
327
328
viewWrapper : { title : String, body : Html Msg } -> Html Msg
329
viewWrapper { title, body } =
330
    H.div [ A.class "space-y-6" ]
331
        [ H.div []
332
            [ H.h2 [ A.class "text-lg font-semibold text-gray-900 mb-4" ] [ H.text title ]
333
            , body
334
            ]
335
        ]