ソースを参照

Merge pull request #1 from stormpath/stormpath-0.1.5

Upgrade to angular-stormpath 0.1.5 with Ionic pages
Matt Raible 7 年 前
コミット
e364574360

+ 1868
- 0
.idea/workspace.xml
ファイル差分が大きすぎるため省略します
ファイルの表示


+ 7
- 1
README.md ファイルの表示

@@ -8,4 +8,10 @@ You can read about how this application was created on [the Stormpath blog](http
8 8
 
9 9
 To run the Spring Boot backend, cd into `server` and run `mvn spring-boot:run`.
10 10
 
11
-In another terminal, cd into `client` and execute `npm install && ionic serve`. The aforementioned blog post shows you how to deploy this app to an emulator/device. 
11
+In another terminal window, install Ionic and Cordova.
12
+
13
+```
14
+npm install -g ionic cordova
15
+```
16
+
17
+Next, cd into `client` and execute `npm install && ionic serve`. The aforementioned blog post shows you how to deploy this app to an emulator/device. 

+ 879
- 0
TUTORIAL.md ファイルの表示

@@ -0,0 +1,879 @@
1
+# Spring Boot, Ionic, and Stormpath
2
+
3
+This tutorial shows how to build a secure Spring Boot API with Stormpath. It also shows how to build an Ionic app that securely connects to this API and can be deployed to a mobile device.
4
+
5
+**Prerequisites**: Java 8, Node.js, Maven, a [Stormpath Account](https://api.stormpath.com/register), and an `apiKey.properties` file in `~/stormpath/`.
6
+
7
+## Spring Boot API
8
+
9
+Create your Spring Boot API project using [start.spring.io](https://start.spring.io).
10
+
11
+```
12
+http https://start.spring.io/starter.zip \
13
+dependencies==data-jpa,data-rest,h2,web,devtools,security,stormpath -d
14
+```
15
+
16
+Run the application with `./mvnw spring-boot:run`.
17
+
18
+Create a `Beer` entity class in `src/main/java/com/example/beer`.
19
+
20
+```java
21
+package com.example.beer;
22
+
23
+import javax.persistence.Entity;
24
+import javax.persistence.GeneratedValue;
25
+import javax.persistence.Id;
26
+
27
+@Entity
28
+public class Beer {
29
+
30
+    @Id
31
+    @GeneratedValue
32
+    private Long id;
33
+    private String name;
34
+
35
+    public Beer() {
36
+    }
37
+
38
+    public Beer(String name) {
39
+        this.name = name;
40
+    }
41
+
42
+    public Long getId() {
43
+        return id;
44
+    }
45
+
46
+    public void setId(Long id) {
47
+        this.id = id;
48
+    }
49
+
50
+    public String getName() {
51
+        return name;
52
+    }
53
+
54
+    public void setName(String name) {
55
+        this.name = name;
56
+    }
57
+
58
+    @Override
59
+    public String toString() {
60
+        return "Beer{" +
61
+                "id=" + id +
62
+                ", name='" + name + '\'' +
63
+                '}';
64
+    }
65
+}
66
+```
67
+
68
+Create a JPA Repository to manage the `Beer` entity.
69
+
70
+```java
71
+package com.example.beer;
72
+
73
+import org.springframework.data.jpa.repository.JpaRepository;
74
+import org.springframework.data.rest.core.annotation.RepositoryRestResource;
75
+
76
+@RepositoryRestResource
77
+interface BeerRepository extends JpaRepository<Beer, Long> {
78
+}
79
+```
80
+
81
+Create a CommandLineRunner to populate the database.
82
+
83
+```java
84
+package com.example.beer;
85
+
86
+import org.springframework.boot.CommandLineRunner;
87
+import org.springframework.stereotype.Component;
88
+
89
+import java.util.stream.Stream;
90
+
91
+@Component
92
+class BeerCommandLineRunner implements CommandLineRunner {
93
+
94
+    public BeerCommandLineRunner(BeerRepository repository) {
95
+        this.repository = repository;
96
+    }
97
+
98
+    @Override
99
+    public void run(String... strings) throws Exception {
100
+        // top 5 beers from https://www.beeradvocate.com/lists/top/
101
+        Stream.of("Good Morning", "Kentucky Brunch Brand Stout", "ManBearPig", "King Julius",
102
+                "Very Hazy", "Budweiser", "Coors Light", "PBR").forEach(name ->
103
+                repository.save(new Beer(name))
104
+        );
105
+        System.out.println(repository.findAll());
106
+    }
107
+
108
+    private final BeerRepository repository;
109
+}
110
+```
111
+
112
+Create a `BeerController` for your REST API. Add some business logic that results in a `/good-beers` endpoint.
113
+
114
+```java
115
+package com.example.beer;
116
+
117
+import org.springframework.web.bind.annotation.GetMapping;
118
+import org.springframework.web.bind.annotation.RestController;
119
+
120
+import java.util.Collection;
121
+import java.util.HashMap;
122
+import java.util.Map;
123
+import java.util.stream.Collectors;
124
+
125
+@RestController
126
+public class BeerController {
127
+    private BeerRepository repository;
128
+
129
+    public BeerController(BeerRepository repository) {
130
+        this.repository = repository;
131
+    }
132
+
133
+    @GetMapping("/good-beers")
134
+    public Collection<Map<String, String>> goodBeers() {
135
+
136
+        return repository.findAll().stream()
137
+                .filter(this::isGreat)
138
+                .map(b -> {
139
+                    Map<String, String> m = new HashMap<>();
140
+                    m.put("id", b.getId().toString());
141
+                    m.put("name", b.getName());
142
+                    return m;
143
+                }).collect(Collectors.toList());
144
+    }
145
+
146
+    private boolean isGreat(Beer beer) {
147
+        return !beer.getName().equals("Budweiser") &&
148
+                !beer.getName().equals("Coors Light") &&
149
+                !beer.getName().equals("PBR");
150
+    }
151
+}
152
+
153
+```
154
+
155
+Access the API using `http localhost:8080/good-beers --auth <user>:<password>`.
156
+
157
+## Ionic App
158
+
159
+Install Ionic and Cordova: `yarn global add cordova ionic`
160
+
161
+From a terminal window, create a new application using the following command:
162
+
163
+```
164
+ionic start ionic-auth --v2
165
+```
166
+
167
+This may take a minute or two to complete, depending on your internet connection speed. In the same terminal window, change to be in your application’s directory and run it.
168
+
169
+```
170
+cd ionic-auth
171
+ionic serve
172
+```
173
+
174
+This will open your default browser on [http://localhost:8100](http://localhost:8100). You can click through the tabbed interface to see the default structure of the app.
175
+
176
+Thanks to the [recent release of Stormpath's Client API](https://stormpath.com/blog/client-api-authentication-mobile-frontend), you can now authenticate directly without needing to hit your server with a Stormpath SDK integration installed. This article shows you how to do just that in an Ionic application.
177
+
178
+Install the [Angular components for Stormpath](https://github.com/stormpath/stormpath-sdk-angular):
179
+
180
+```
181
+yarn add angular-stormpath
182
+```
183
+
184
+Modify `app.module.ts` to import the appropriate Stormpath classes from `angular-stormpath`. Create a function to configure the `endpointPrefix` to point to the DNS label for your Client API instance. 
185
+
186
+**NOTE:** You can find and configure your DNS label by logging into https://api.stormpath.com and navigating to Applications > My Application > Policies > Client API > DNS Label. Since mine is “raible”, I’ll be using `raible.apps.stormpath.io` for this example.
187
+
188
+Make sure to define `stormpathConfig`, override the provider, import `StormpathModule` / `StormpathIonicModule`, and append Stormpath's pre-built Ionic pages to `entryComponents`.
189
+
190
+
191
+```typescript
192
+import {
193
+  StormpathConfiguration, StormpathModule, StormpathIonicModule,
194
+  LoginPage, RegisterPage, ForgotPasswordPage
195
+} from 'angular-stormpath';
196
+...
197
+export function stormpathConfig(): StormpathConfiguration {
198
+  let spConfig: StormpathConfiguration = new StormpathConfiguration();
199
+  spConfig.endpointPrefix = 'https://raible.apps.stormpath.io';
200
+  return spConfig;
201
+}
202
+
203
+@NgModule({
204
+  ...
205
+  imports: [
206
+    IonicModule.forRoot(MyApp),
207
+    StormpathModule,
208
+    StormpathIonicModule
209
+  ],
210
+  bootstrap: [IonicApp],
211
+  entryComponents: [
212
+    ...
213
+    LoginPage,
214
+    ForgotPasswordPage,
215
+    RegisterPage
216
+  ],
217
+  providers: [
218
+    {provide: ErrorHandler, useClass: IonicErrorHandler},
219
+    {provide: StormpathConfiguration, useFactory: stormpathConfig}
220
+  ]
221
+})
222
+export class AppModule {}
223
+```
224
+
225
+To render a login page before users can view the application, you can modify `src/app/app.component.ts` to use the `Stormpath` service and navigate to Stormpath's `LoginPage` if the user is not authenticated. 
226
+
227
+```typescript
228
+import { Component } from '@angular/core';
229
+import { Platform } from 'ionic-angular';
230
+import { StatusBar, Splashscreen } from 'ionic-native';
231
+import { TabsPage } from '../pages/tabs/tabs';
232
+import { Stormpath, LoginPage } from 'angular-stormpath';
233
+
234
+@Component({
235
+  templateUrl: 'app.html'
236
+})
237
+export class MyApp {
238
+  rootPage;
239
+
240
+  constructor(platform: Platform, private stormpath: Stormpath) {
241
+    stormpath.user$.subscribe(user => {
242
+      if (!user) {
243
+        this.rootPage = LoginPage;
244
+      } else {
245
+        this.rootPage = TabsPage;
246
+      }
247
+    });
248
+
249
+    platform.ready().then(() => {
250
+      // Okay, so the platform is ready and our plugins are available.
251
+      // Here you can do any higher level native things you might need.
252
+      StatusBar.styleDefault();
253
+      Splashscreen.hide();
254
+    });
255
+  }
256
+}
257
+```
258
+
259
+If you run `ionic serve`, you’ll likely see something similar to the following error in your browser’s console.
260
+
261
+```
262
+XMLHttpRequest cannot load https://raible.apps.stormpath.io/me. Response to preflight request
263
+doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on 
264
+the requested resource. Origin 'http://localhost:8100 is therefore not allowed access. 
265
+The response had HTTP status code 403.
266
+```
267
+
268
+To fix this, you’ll need to login to https://api.stormpath.com, navigate to Applications > My Application, and modify the **Authorized Origin URIs** to include `http://localhost:8100`. 
269
+
270
+At this point, you should see a login screen when you run `ionic serve`.
271
+
272
+![Stormpath Login for Ionic](./static/ionic-login.png)
273
+
274
+If you don’t see this screen, it’s possible you’re still logged in. Clearing your local storage will fix this, or you can continue below to add the ability to logout.
275
+
276
+In `src/pages/home.html`, add a logout link to the header and a paragraph in the content section that shows the currently logged in user.
277
+
278
+```html
279
+<ion-header>
280
+  <ion-navbar>
281
+    <ion-title>Home</ion-title>
282
+    <ion-buttons end>
283
+      <button ion-button icon-only (click)="logout()">
284
+        Logout
285
+      </button>
286
+    </ion-buttons>
287
+  </ion-navbar>
288
+</ion-header>
289
+
290
+<ion-content padding>
291
+  ...
292
+  <p *ngIf="(user$ | async)">
293
+    You are logged in as: <b>{{ ( user$ | async ).fullName }}</b>
294
+  </p>
295
+</ion-content>
296
+```
297
+
298
+If you login, the “Logout” button will render, but won’t work because there’s no `logout()` method in `src/pages/home.ts`. Similarly, the “You are logged in” message won’t appear because there’s no `user$` variable defined. Change the body of `home.ts` to retrieve `user$` from the `Stormpath` service and define the `logout()` method.
299
+
300
+```typescript
301
+import { Account, Stormpath } from 'angular-stormpath';
302
+import { Observable } from 'rxjs';
303
+...
304
+export class HomePage {
305
+  user$: Observable<Account | boolean>;
306
+
307
+  constructor(private stormpath: Stormpath) {
308
+    this.user$ = this.stormpath.user$;
309
+  }
310
+
311
+  logout(): void {
312
+    this.stormpath.logout();
313
+  }
314
+}
315
+```
316
+
317
+If you’re logged in, you should see a screen with a logout button and the name of the currently logged in user.
318
+
319
+![Logged in as: Hip User](./static/ionic-home.png)
320
+
321
+The `LoginPage` tries to auto-focus onto the `email` field when it loads. To auto-activate the keyboard you'll need to tell Cordova it’s OK to display the keyboard without user interaction. You can do this by adding the following to `config.xml` in the root directory.
322
+
323
+```xml
324
+<preference name="KeyboardDisplayRequiresUserAction" value="false" />
325
+```
326
+
327
+Check your changes into Git.
328
+
329
+```
330
+git add .
331
+git commit -m "Add Stormpath"
332
+```
333
+
334
+## Build a Good Beers UI
335
+
336
+Run `ionic generate page beer` to create a component and a template to display the list of good beers. 
337
+
338
+Add `BeerPage` to the `declarations` and `entryComponent` lists in `app.module.ts`.
339
+
340
+Run `ionic generate provider beer-service` to create a service to fetch the beer list from the Spring Boot API.
341
+
342
+Change `src/providers/beer-service.ts` to use have a `getGoodBeers()` method.
343
+
344
+```typescript
345
+import { Injectable } from '@angular/core';
346
+import { Http, Response } from '@angular/http';
347
+import 'rxjs/add/operator/map';
348
+import { Observable } from 'rxjs';
349
+import { StormpathConfiguration } from 'angular-stormpath';
350
+
351
+@Injectable()
352
+export class BeerService {
353
+  public API;
354
+  public BEER_API;
355
+
356
+  constructor(public http: Http, public config: StormpathConfiguration) {
357
+    this.API = config.endpointPrefix;
358
+    this.BEER_API = this.API + '/beers';
359
+  }
360
+
361
+  getGoodBeers(): Observable<any> {
362
+    return this.http.get(this.API + '/good-beers')
363
+      .map((response: Response) => response.json());
364
+  }
365
+}
366
+```
367
+
368
+Modify `beer.html` to show the list of beers.
369
+
370
+```html
371
+<ion-header>
372
+  <ion-navbar>
373
+    <ion-title>Good Beers</ion-title>
374
+  </ion-navbar>
375
+
376
+</ion-header>
377
+
378
+<ion-content padding>
379
+  <ion-list>
380
+    <ion-item *ngFor="let beer of beers" >
381
+      <ion-item>
382
+        <h2>{{beer.name}}</h2>
383
+      </ion-item>
384
+    </ion-item>
385
+  </ion-list>
386
+</ion-content>
387
+```
388
+
389
+Update `beer.ts` to import `BeerService` and add as a provider. Call the `getGoodBeers()` method in the `ionViewDidLoad()` lifecycle method.
390
+
391
+```typescript
392
+import { Component } from '@angular/core';
393
+import { BeerService } from '../../providers/beer-service';
394
+
395
+@Component({
396
+  selector: 'page-beer',
397
+  templateUrl: 'beer.html',
398
+  providers: [BeerService]
399
+})
400
+export class BeerPage {
401
+  private beers: Array<any>;
402
+
403
+  constructor(public beerService: BeerService) {
404
+  }
405
+
406
+  ionViewDidLoad() {
407
+    this.beerService.getGoodBeers().subscribe(beers => {
408
+      this.beers = beers;
409
+    })
410
+  }
411
+}
412
+```
413
+
414
+To expose this page on the tab bar, add it to `tabs.ts`
415
+
416
+```typescript
417
+import { Component } from '@angular/core';
418
+
419
+import { HomePage } from '../home/home';
420
+import { AboutPage } from '../about/about';
421
+import { ContactPage } from '../contact/contact';
422
+import { BeerPage } from '../beer/beer';
423
+
424
+@Component({
425
+  templateUrl: 'tabs.html'
426
+})
427
+export class TabsPage {
428
+  // this tells the tabs component which Pages
429
+  // should be each tab's root Page
430
+  tab1Root: any = HomePage;
431
+  tab2Root: any = BeerPage;
432
+  tab3Root: any = ContactPage;
433
+  tab4Root: any = AboutPage;
434
+
435
+  constructor() {
436
+  }
437
+}
438
+```
439
+
440
+Update `tabs.html` too!
441
+
442
+```html
443
+<ion-tabs>
444
+  <ion-tab [root]="tab1Root" tabTitle="Home" tabIcon="home"></ion-tab>
445
+  <ion-tab [root]="tab2Root" tabTitle="Beer" tabIcon="beer"></ion-tab>
446
+  <ion-tab [root]="tab3Root" tabTitle="Contact" tabIcon="contacts"></ion-tab>
447
+  <ion-tab [root]="tab4Root" tabTitle="About" tabIcon="information-circle"></ion-tab>
448
+</ion-tabs>
449
+```
450
+
451
+Add some fun with Giphy! Run `ionic generate provider giphy-service`. Replace the code in `src/providers/giphy-service.ts` with the following TypeScript:
452
+
453
+```typescript
454
+import { Injectable } from '@angular/core';
455
+import { Http, Response } from '@angular/http';
456
+import { Observable } from 'rxjs';
457
+
458
+@Injectable()
459
+// http://tutorials.pluralsight.com/front-end-javascript/getting-started-with-angular-2-by-building-a-giphy-search-application
460
+export class GiphyService {
461
+
462
+  giphyApi = 'https://api.giphy.com/v1/gifs/search?api_key=dc6zaTOxFJmzC&q=';
463
+
464
+  constructor(public http: Http) {
465
+  }
466
+
467
+  get(searchTerm): Observable<any> {
468
+    let apiLink = this.giphyApi + searchTerm;
469
+    return this.http.request(apiLink).map((res: Response) => {
470
+      let results = res.json().data;
471
+      if (results.length > 0) {
472
+        return results[0].images.original.url;
473
+      } else {
474
+        return 'https://media.giphy.com/media/YaOxRsmrv9IeA/giphy.gif'; // dancing cat for 404
475
+      }
476
+    });
477
+  }
478
+}
479
+```
480
+
481
+Update `beer.ts` to take advantage of `GiphyService`:
482
+
483
+```typescript
484
+import { Component } from '@angular/core';
485
+import { BeerService } from '../../providers/beer-service';
486
+import { GiphyService } from '../../providers/giphy-service';
487
+
488
+@Component({
489
+  selector: 'page-beer',
490
+  templateUrl: 'beer.html',
491
+  providers: [BeerService, GiphyService]
492
+})
493
+export class BeerPage {
494
+  private beers: Array<any>;
495
+
496
+  constructor(public beerService: BeerService, public giphyService: GiphyService) {
497
+  }
498
+
499
+  ionViewDidLoad() {
500
+    this.beerService.getGoodBeers().subscribe(beers => {
501
+      this.beers = beers;
502
+      for (let beer of this.beers) {
503
+        this.giphyService.get(beer.name).subscribe(url => {
504
+          beer.giphyUrl = url
505
+        });
506
+      }
507
+    })
508
+  }
509
+}
510
+```
511
+
512
+Update `beer.html` to display the image retrieved:
513
+
514
+```html
515
+<ion-item>
516
+    <ion-avatar item-left>
517
+      <img src="{{beer.giphyUrl}}">
518
+    </ion-avatar>
519
+    <h2>{{beer.name}}</h2>
520
+</ion-item>
521
+```
522
+
523
+If everything works as expected, you should see a page similar to the one below in your browser.
524
+
525
+<p align="center">
526
+<img src="./static/good-beers-ui.png" width="600" alt="Good Beers UI">
527
+</p>
528
+
529
+### Add a Modal for Editing
530
+
531
+Change the header in `beer.html` to have a button that opens a modal to add a new beer.
532
+
533
+```html
534
+<ion-header>
535
+  <ion-navbar>
536
+    <ion-title>Good Beers</ion-title>
537
+    <ion-buttons end>
538
+      <button ion-button icon-only (click)="openModal()" color="primary">
539
+        <ion-icon name="add-circle"></ion-icon>
540
+        <ion-icon name="beer"></ion-icon>
541
+      </button>
542
+    </ion-buttons>
543
+  </ion-navbar>
544
+```
545
+
546
+In this same file, change `<ion-item>` to have a click handler for opening the modal for the current item.
547
+
548
+```html
549
+<ion-item (click)="openModal({id: beer.id})">
550
+```
551
+
552
+Add `ModalController` as a dependency in `BeerPage` and add an `openModal()` method.
553
+
554
+```typescript
555
+export class BeerPage {
556
+  private beers: Array<any>;
557
+
558
+  constructor(public beerService: BeerService, public giphyService: GiphyService,
559
+              public modalCtrl: ModalController) {
560
+  }
561
+
562
+  // ionViewDidLoad method
563
+
564
+  openModal(beerId) {
565
+    let modal = this.modalCtrl.create(BeerModalPage, beerId);
566
+    modal.present();
567
+    // refresh data after modal dismissed
568
+    modal.onDidDismiss(() => this.ionViewDidLoad())
569
+  }
570
+}
571
+```
572
+
573
+This won't compile because `BeerModalPage` doesn't exist. Create `beer-modal.ts` in the same directory. This page will retrieve the beer from the `beerId` that's passed in. It will render the name, allow it to be edited, and show the Giphy image found for the name.
574
+
575
+```typescript
576
+import { BeerService } from '../../providers/beer-service';
577
+import { Component, ViewChild } from '@angular/core';
578
+import { GiphyService } from '../../providers/giphy-service';
579
+import { NavParams, ViewController, ToastController, NavController } from 'ionic-angular';
580
+import { NgForm } from '@angular/forms';
581
+
582
+@Component({
583
+  templateUrl: './beer-modal.html'
584
+})
585
+export class BeerModalPage {
586
+  @ViewChild('name') name;
587
+  beer: any = {};
588
+  error: any;
589
+
590
+  constructor(public beerService: BeerService,
591
+              public giphyService: GiphyService,
592
+              public params: NavParams,
593
+              public viewCtrl: ViewController,
594
+              public toastCtrl: ToastController,
595
+              public navCtrl: NavController) {
596
+    if (this.params.data.id) {
597
+      this.beerService.get(this.params.get('id')).subscribe(beer => {
598
+        this.beer = beer;
599
+        this.beer.href = beer._links.self.href;
600
+        this.giphyService.get(beer.name).subscribe(url => beer.giphyUrl = url);
601
+      });
602
+    }
603
+  }
604
+
605
+  dismiss() {
606
+    this.viewCtrl.dismiss();
607
+  }
608
+
609
+  save(form: NgForm) {
610
+    let update: boolean = form['href'];
611
+    this.beerService.save(form).subscribe(result => {
612
+      let toast = this.toastCtrl.create({
613
+        message: 'Beer "' + form.name + '" ' + ((update) ? 'updated' : 'added') + '.',
614
+        duration: 2000
615
+      });
616
+      toast.present();
617
+      this.dismiss();
618
+    }, error => this.error = error)
619
+  }
620
+
621
+  ionViewDidLoad() {
622
+    setTimeout(() => {
623
+      this.name.setFocus();
624
+    },150);
625
+  }
626
+}
627
+```
628
+
629
+Create `beer-modal.html` as a template for this page.
630
+
631
+```html
632
+<ion-header>
633
+  <ion-toolbar>
634
+    <ion-title>
635
+      {{beer ? 'Beer Details' : 'Add Beer'}}
636
+    </ion-title>
637
+    <ion-buttons start>
638
+      <button ion-button (click)="dismiss()">
639
+        <span ion-text color="primary" showWhen="ios,core">Cancel</span>
640
+        <ion-icon name="md-close" showWhen="android,windows"></ion-icon>
641
+      </button>
642
+    </ion-buttons>
643
+  </ion-toolbar>
644
+</ion-header>
645
+<ion-content padding>
646
+  <form #beerForm="ngForm" (ngSubmit)="save(beerForm.value)">
647
+    <input type="hidden" name="href" [(ngModel)]="beer.href">
648
+    <ion-row>
649
+      <ion-col>
650
+        <ion-list inset>
651
+          <ion-item>
652
+            <ion-input placeholder="Beer Name" name="name" type="text"
653
+                       required [(ngModel)]="beer.name" #name></ion-input>
654
+          </ion-item>
655
+        </ion-list>
656
+      </ion-col>
657
+    </ion-row>
658
+    <ion-row>
659
+      <ion-col *ngIf="beer" text-center>
660
+        <img src="{{beer.giphyUrl}}">
661
+      </ion-col>
662
+    </ion-row>
663
+    <ion-row>
664
+      <ion-col>
665
+        <div *ngIf="error" class="alert alert-danger">{{error}}</div>
666
+        <button ion-button color="primary" full type="submit"
667
+                [disabled]="!beerForm.form.valid">Save</button>
668
+      </ion-col>
669
+    </ion-row>
670
+  </form>
671
+</ion-content>
672
+```
673
+
674
+Add `BeerModalPage` to the `declarations` and `entryComponent` lists in `app.module.ts`.
675
+
676
+You'll also need to modify `beer-service.ts` to have `get()` and `save()` methods.
677
+
678
+```typescript
679
+get(id: string) {
680
+  return this.http.get(this.BEER_API + '/' + id)
681
+    .map((response: Response) => response.json());
682
+}
683
+
684
+save(beer: any): Observable<any> {
685
+  let result: Observable<Response>;
686
+  if (beer['href']) {
687
+    result = this.http.put(beer.href, beer);
688
+  } else {
689
+    result = this.http.post(this.BEER_API, beer)
690
+  }
691
+  return result.map((response: Response) => response.json())
692
+    .catch(error => Observable.throw(error));
693
+}
694
+```
695
+
696
+### Add Swipe to Delete
697
+
698
+To add swipe-to-delete functionality on the list of beers, open `beer.html` and make it so `<ion-item-sliding>` wraps `<ion-item>` and contains the `*ngFor`. Add a delete button using `<ion-item-options>`.
699
+
700
+```html
701
+<ion-content padding>
702
+  <ion-list>
703
+    <ion-item-sliding *ngFor="let beer of beers" >
704
+      <ion-item (click)="openModal({id: beer.id})">
705
+        <ion-avatar item-left>
706
+          <img src="{{beer.giphyUrl}}">
707
+        </ion-avatar>
708
+        <h2>{{beer.name}}</h2>
709
+      </ion-item>
710
+      <ion-item-options>
711
+        <button ion-button color="danger" (click)="remove(beer)"><ion-icon name="trash"></ion-icon> Delete</button>
712
+      </ion-item-options>
713
+    </ion-item-sliding>
714
+  </ion-list>
715
+</ion-content>
716
+```
717
+
718
+Add a `remove()` method to `beer.ts`. 
719
+
720
+```typescript
721
+remove(beer) {
722
+  this.beerService.remove(beer.id).subscribe(response => {
723
+    for (let i = 0; i < this.beers.length; i++) {
724
+      if (this.beers[i] === beer) {
725
+        this.beers.splice(i, 1);
726
+        let toast = this.toastCtrl.create({
727
+          message: 'Beer "' + beer.name + '" deleted.',
728
+          duration: 2000,
729
+          position: 'top'
730
+        });
731
+        toast.present();
732
+      }
733
+    }
734
+  });
735
+}
736
+```
737
+
738
+Add `toastCtrl` as a dependency in the constructor so everything compiles.
739
+
740
+```typescript
741
+constructor(public beerService: BeerService, public giphyService: GiphyService,
742
+          public modalCtrl: ModalController, public toastCtrl: ToastController) {
743
+}
744
+```
745
+
746
+You'll also need to modify `beer-service.ts` to have a `remove()` method.
747
+
748
+```typescript
749
+remove(id: string) {
750
+  return this.http.delete(this.BEER_API + '/' + id)
751
+    .map((response: Response) => response.json());
752
+}
753
+```
754
+
755
+After making these additions, you should be able to add, edit and delete beers.
756
+
757
+<p align="center">
758
+<img src="./static/beer-modal.png" width="350">&nbsp;&nbsp;
759
+<img src="./static/beer-delete.png" width="350">
760
+</div>
761
+
762
+## PWAs with Ionic
763
+
764
+Ionic 2 ships with support for creating progressive web apps (PWAs). If you’d like to learn more about what PWAs are, see [Navigating the World of Progressive Web Apps with Ionic 2](http://blog.ionic.io/navigating-the-world-of-progressive-web-apps-with-ionic-2/). 
765
+
766
+If you run the [Lighthouse Chrome extension](https://developers.google.com/web/tools/lighthouse/) on this application, you’ll get a mediocre score (54/100).
767
+
768
+To register a service worker, and improve the app’s score, uncomment the following block in `index.html`.
769
+
770
+```html
771
+<!-- un-comment this code to enable service worker
772
+<script>
773
+  if ('serviceWorker' in navigator) {
774
+    navigator.serviceWorker.register('service-worker.js')
775
+      .then(() => console.log('service worker installed'))
776
+      .catch(err => console.log('Error', err));
777
+  }
778
+</script>-->
779
+```
780
+
781
+After making this change, the score should improve. In my tests, it increased to 69/100. The remaining issues were:
782
+
783
+* The page body should render some content if its scripts are not available. This could likely be solved with [Angular’s app-shell directives](https://www.npmjs.com/package/@angular/app-shell). 
784
+* Site is not on HTTPS and does not redirect HTTP to HTTPS.
785
+* A couple -1’s in performance for "Cannot read property 'ts' of undefined”. 
786
+
787
+If you refresh the app and Chrome doesn’t prompt you to install the app (a PWA feature), you probably need to turn on a couple of features. Copy and paste the following URLs into Chrome and enable each feature.
788
+
789
+```
790
+chrome://flags/#bypass-app-banner-engagement-checks
791
+chrome://flags/#enable-add-to-shelf
792
+```
793
+
794
+After enabling these flags, you’ll see an error in your browser’s console about `assets/imgs/logo.png` not being found. This files is referenced in `src/manifest.json`. You can fix this by copying a 512x512 PNG into this location or by modifying `manifest.json` accordingly.
795
+
796
+## Deploy to a Mobile Device
797
+
798
+It’s pretty cool that you’re able to develop mobile apps with Ionic in your browser. However, it’s nice to see the fruits of your labor and see how awesome your app looks on a phone. It really does look and behave like a native app!
799
+
800
+To see how your application will look on different devices you can run `ionic serve --lab`. The `--lab` flag opens opens a page in your browser that lets you see how your app looks on different devices. 
801
+
802
+### iOS
803
+
804
+To emulate or deploy to an iOS device, you’ll need a Mac and a fresh installation of [Xcode](https://developer.apple.com/xcode/). If you’d like to build iOS apps on Windows, Ionic offers an [Ionic Package](http://ionic.io/cloud#packaging) service.
805
+
806
+Make sure to open Xcode to complete the installation.
807
+
808
+To make your app iOS-capable, add support for it using the following command.
809
+
810
+```
811
+ionic platform add ios
812
+```
813
+
814
+You’ll need to run `ionic emulate ios` to open your app in Simulator.
815
+
816
+The biggest problem I found when running the app in Simulator was that it was difficult to get the keyboard to popup. To workaround this, I used Edit > Hardware > Keyboard > Toggle Software Keyboard when I needed to type text in a field.
817
+
818
+To deploy the app to an iPhone, start by plugging your iOS device into your computer. Then run the following commands to install ios-deploy/ios-sim, build the app, and run it on your device.
819
+
820
+```
821
+npm install -g ios-deploy ios-sim
822
+ionic build ios --prod
823
+cd platforms/ios/
824
+open ionic-auth.xcodeproj
825
+```
826
+
827
+Select your phone as the target in Xcode and click the play button to run your app. The first time you do this, Xcode may spin for a while with a “Processing symbol files” message at the top.
828
+
829
+See Ionic’s [deploying documentation](https://ionicframework.com/docs/v2/setup/deploying/) for information on code signing and trusting the app’s certificate.
830
+
831
+Once you’re configured your phone, computer, and Apple ID to work, you should be able to open the app and see screens like the ones I captured on my iPhone 6s Plus.
832
+
833
+<p align="center">
834
+<img src="./static/iphone-login.png" width="250">&nbsp;&nbsp;
835
+<img src="./static/iphone-register.png" width="250">&nbsp;&nbsp;
836
+<img src="./static/iphone-forgot-password.png" width="250">
837
+</div>
838
+
839
+### Android
840
+
841
+To emulate or deploy to an Android device, you’ll first need to install [Android Studio](https://developer.android.com/studio/index.html). As part of the install, it will show you where it installed the Android SDK. Set this path as an ANDROID_HOME environment variable. On a Mac, it should be `~/Library/Android/sdk/`.
842
+
843
+Make sure to open Android Studio to complete the installation.
844
+
845
+To deploy to the Android emulator, add support for it to the ionic-auth project using the `ionic` command.
846
+
847
+```
848
+ionic platform add android
849
+```
850
+
851
+If you run `ionic emulate android` you’ll get instructions from about how to create an emulator image.
852
+
853
+```
854
+Error: No emulator images (avds) found.
855
+1. Download desired System Image by running: /Users/mraible/Library/Android/sdk/tools/android sdk
856
+2. Create an AVD by running: /Users/mraible/Library/Android/sdk/tools/android avd
857
+HINT: For a faster emulator, use an Intel System Image and install the HAXM device driver
858
+```
859
+
860
+I ran the first suggestion and downloaded my desired system image. Then I ran the second command and created an AVD with the following settings:
861
+
862
+```
863
+AVD Name: TestPhone
864
+Device: Nexus 5
865
+Target: Android 7.1.1
866
+CPU/ABI: Google APIs Intel Axom (x86_64)
867
+Skin: Skin with dynamic hardware controls
868
+```
869
+
870
+After performing these steps, I was able to run `ionic emulate android` and see my app running in the AVD.
871
+
872
+## Learn More
873
+I hope you’ve enjoyed this tour of Ionic, Angular, and Stormpath. I like how Ionic takes your web development skills up a notch and allows you to create mobile applications that look and behave natively.
874
+
875
+To learn more about Ionic, Angular, or Stormpath, please see the following resources:
876
+
877
+* [Get started with Ionic Framework](http://ionicframework.com/getting-started/)
878
+* [Getting Started with Angular](https://www.youtube.com/watch?v=Jq3szz2KOOs) A YouTube webinar by yours truly. ;)
879
+* [Stormpath Client API Guide](https://docs.stormpath.com/client-api/product-guide/latest/) 

+ 10
- 10
client/package.json ファイルの表示

@@ -10,17 +10,17 @@
10 10
     "ionic:serve": "ionic-app-scripts serve"
11 11
   },
12 12
   "dependencies": {
13
-    "@angular/common": "2.3.1",
14
-    "@angular/compiler": "2.3.1",
15
-    "@angular/compiler-cli": "2.3.1",
16
-    "@angular/core": "2.3.1",
17
-    "@angular/forms": "2.3.1",
18
-    "@angular/http": "2.3.1",
19
-    "@angular/platform-browser": "2.3.1",
20
-    "@angular/platform-browser-dynamic": "2.3.1",
21
-    "@angular/platform-server": "2.3.1",
13
+    "@angular/common": "2.2.1",
14
+    "@angular/compiler": "2.2.1",
15
+    "@angular/compiler-cli": "2.2.1",
16
+    "@angular/core": "2.2.1",
17
+    "@angular/forms": "2.2.1",
18
+    "@angular/http": "2.2.1",
19
+    "@angular/platform-browser": "2.2.1",
20
+    "@angular/platform-browser-dynamic": "2.2.1",
21
+    "@angular/platform-server": "2.2.1",
22 22
     "@ionic/storage": "1.1.7",
23
-    "angular-stormpath": "^0.1.2",
23
+    "angular-stormpath": "^0.1.5",
24 24
     "ionic-angular": "2.0.0",
25 25
     "ionic-native": "2.4.1",
26 26
     "ionicons": "3.0.0",

+ 1
- 2
client/src/app/app.component.ts ファイルの表示

@@ -2,8 +2,7 @@ import { Component } from '@angular/core';
2 2
 import { Platform } from 'ionic-angular';
3 3
 import { StatusBar, Splashscreen } from 'ionic-native';
4 4
 import { TabsPage } from '../pages/tabs/tabs';
5
-import { Stormpath } from 'angular-stormpath';
6
-import { LoginPage } from '../pages/auth/login/login';
5
+import { Stormpath, LoginPage } from 'angular-stormpath';
7 6
 
8 7
 @Component({
9 8
   templateUrl: 'app.html'

+ 6
- 8
client/src/app/app.module.ts ファイルの表示

@@ -5,10 +5,10 @@ import { AboutPage } from '../pages/about/about';
5 5
 import { ContactPage } from '../pages/contact/contact';
6 6
 import { HomePage } from '../pages/home/home';
7 7
 import { TabsPage } from '../pages/tabs/tabs';
8
-import { StormpathConfiguration, StormpathModule } from 'angular-stormpath';
9
-import { LoginPage } from '../pages/auth/login/login';
10
-import { ForgotPasswordPage } from '../pages/auth/forgot/forgot';
11
-import { RegisterPage } from '../pages/auth/register/register';
8
+import {
9
+  StormpathConfiguration, StormpathModule, StormpathIonicModule,
10
+  LoginPage, RegisterPage, ForgotPasswordPage
11
+} from 'angular-stormpath';
12 12
 import { BeerPage } from '../pages/beer/beer';
13 13
 import { BeerService } from '../providers/beer-service';
14 14
 import { GiphyService } from '../providers/giphy-service';
@@ -28,15 +28,13 @@ export function stormpathConfig(): StormpathConfiguration {
28 28
     ContactPage,
29 29
     HomePage,
30 30
     TabsPage,
31
-    LoginPage,
32
-    ForgotPasswordPage,
33
-    RegisterPage,
34 31
     BeerPage,
35 32
     BeerModalPage
36 33
   ],
37 34
   imports: [
38 35
     IonicModule.forRoot(MyApp),
39
-    StormpathModule
36
+    StormpathModule,
37
+    StormpathIonicModule
40 38
   ],
41 39
   bootstrap: [IonicApp],
42 40
   entryComponents: [

+ 0
- 32
client/src/pages/auth/forgot/forgot.html ファイルの表示

@@ -1,32 +0,0 @@
1
-<ion-header>
2
-  <ion-navbar>
3
-    <ion-title>
4
-      Forgot Password
5
-    </ion-title>
6
-  </ion-navbar>
7
-</ion-header>
8
-<ion-content padding>
9
-  <p *ngIf="sent">
10
-    We have sent a password reset link to the email address of the account that you specified.
11
-    Please check your email for this message, then click on the link.<br>
12
-    <button ion-button type="button" (click)="showLogin()">Back to Login</button>
13
-  </p>
14
-  <ion-row>
15
-    <ion-col>
16
-      <form *ngIf="!sent" #registerForm="ngForm" (ngSubmit)="onSubmit(registerForm.value)" autocomplete="off">
17
-        <ion-list inset>
18
-          <ion-item>
19
-            <ion-input name="email" type="email" id="spEmail" [(ngModel)]="forgotPasswordFormModel.email"
20
-                       placeholder="Your Email Address" [disabled]="posting" required></ion-input>
21
-          </ion-item>
22
-        </ion-list>
23
-        <ion-row>
24
-          <ion-col>
25
-            <p class="text-danger" *ngIf="error">{{error}}</p>
26
-            <button ion-button type="submit" full [disabled]="!registerForm.form.valid || posting">Request Password Reset</button>
27
-          </ion-col>
28
-        </ion-row>
29
-      </form>
30
-    </ion-col>
31
-  </ion-row>
32
-</ion-content>

+ 0
- 8
client/src/pages/auth/forgot/forgot.ts ファイルの表示

@@ -1,8 +0,0 @@
1
-import { ForgotPasswordComponent } from 'angular-stormpath';
2
-import { Component } from '@angular/core';
3
-
4
-@Component({
5
-  selector: 'page-forgot-password',
6
-  templateUrl: './forgot.html'
7
-})
8
-export class ForgotPasswordPage extends ForgotPasswordComponent {}

+ 0
- 33
client/src/pages/auth/login/login.html ファイルの表示

@@ -1,33 +0,0 @@
1
-<ion-header>
2
-  <ion-navbar>
3
-    <ion-title>
4
-      Login
5
-    </ion-title>
6
-  </ion-navbar>
7
-</ion-header>
8
-<ion-content padding>
9
-  <form #loginForm="ngForm" (ngSubmit)="login(loginForm.value)" autocomplete="off">
10
-    <ion-row>
11
-      <ion-col>
12
-        <ion-list inset>
13
-          <ion-item>
14
-            <ion-input placeholder="Email" name="login" id="loginField" type="text"
15
-                       required [(ngModel)]="loginFormModel.login" #email></ion-input>
16
-          </ion-item>
17
-          <ion-item>
18
-            <ion-input placeholder="Password" name="password" id="passwordField"
19
-                       type="password" required [(ngModel)]="loginFormModel.password"></ion-input>
20
-          </ion-item>
21
-        </ion-list>
22
-      </ion-col>
23
-    </ion-row>
24
-    <ion-row>
25
-      <ion-col>
26
-        <div *ngIf="error" class="alert alert-danger">{{error}}</div>
27
-        <button ion-button class="submit-btn" full type="submit" [disabled]="!loginForm.form.valid">Login</button>
28
-        <button ion-button class="forgot-btn" type="button" block clear (click)="forgot()">Forgot Password?</button>
29
-        <button ion-button class="create-btn" type="button" block clear (click)="register()">Create Account</button>
30
-      </ion-col>
31
-    </ion-row>
32
-  </form>
33
-</ion-content>

+ 0
- 32
client/src/pages/auth/login/login.ts ファイルの表示

@@ -1,32 +0,0 @@
1
-import { Component, ViewChild } from '@angular/core';
2
-import { LoginComponent} from 'angular-stormpath';
3
-import { NavController } from 'ionic-angular';
4
-import { Stormpath, LoginService } from 'angular-stormpath';
5
-import { ForgotPasswordPage } from '../forgot/forgot';
6
-import { RegisterPage } from '../register/register';
7
-
8
-@Component({
9
-  selector: 'page-login',
10
-  templateUrl: './login.html'
11
-})
12
-export class LoginPage extends LoginComponent {
13
-  @ViewChild('email') email;
14
-
15
-  constructor(stormpath: Stormpath, loginService: LoginService, private nav: NavController) {
16
-    super(stormpath, loginService);
17
-  }
18
-
19
-  forgot() {
20
-    this.nav.push(ForgotPasswordPage);
21
-  }
22
-
23
-  register() {
24
-    this.nav.push(RegisterPage);
25
-  }
26
-
27
-  ionViewDidLoad() {
28
-    setTimeout(() => {
29
-      this.email.setFocus();
30
-    },150);
31
-  }
32
-}

+ 0
- 35
client/src/pages/auth/register/register.html ファイルの表示

@@ -1,35 +0,0 @@
1
-<ion-header>
2
-  <ion-navbar>
3
-    <ion-title>
4
-      Create Account
5
-    </ion-title>
6
-  </ion-navbar>
7
-</ion-header>
8
-<ion-content padding>
9
-  <form *ngIf="!registered" (ngSubmit)="onSubmit()" autocomplete="off" #registerForm="ngForm">
10
-    <ion-row>
11
-      <ion-col>
12
-        <ion-list inset>
13
-          <ion-item *ngFor="let field of model?.form?.fields">
14
-            <ion-input [name]="field.name" [id]="field.name" [type]="field.type"
15
-                       [(ngModel)]="formModel[field.name]" [placeholder]="field.placeholder"
16
-                       [disabled]="creating" [required]="field.required"></ion-input>
17
-          </ion-item>
18
-        </ion-list>
19
-      </ion-col>
20
-    </ion-row>
21
-    <ion-row>
22
-      <ion-col>
23
-        <div *ngIf="error" class="alert alert-danger">{{error}}</div>
24
-        <button ion-button type="submit" full [disabled]="!registerForm.form.valid">Register</button>
25
-      </ion-col>
26
-    </ion-row>
27
-  </form>
28
-  <p *ngIf="unverified" class="alert alert-success">
29
-    Your account has been created and requires verification.
30
-    Please check your email for a verification link.
31
-  </p>
32
-  <p class="alert alert-success" *ngIf="canLogin">
33
-    Your account has been created, you may now log in.
34
-  </p>
35
-</ion-content>

+ 0
- 14
client/src/pages/auth/register/register.ts ファイルの表示

@@ -1,14 +0,0 @@
1
-import { Component } from '@angular/core';
2
-import { RegisterComponent } from 'angular-stormpath';
3
-
4
-@Component({
5
-  selector: 'page-register',
6
-  templateUrl: './register.html'
7
-})
8
-export class RegisterPage extends RegisterComponent {
9
-
10
-  // fix for view model not always loading
11
-  ionViewDidLoad(): void {
12
-    super.ngOnInit();
13
-  }
14
-}

+ 0
- 1
client/src/pages/beer/beer-modal.ts ファイルの表示

@@ -3,7 +3,6 @@ import { Component, ViewChild } from '@angular/core';
3 3
 import { GiphyService } from '../../providers/giphy-service';
4 4
 import { NavParams, ViewController, ToastController, NavController } from 'ionic-angular';
5 5
 import { NgForm } from '@angular/forms';
6
-import { BeerPage } from './beer';
7 6
 
8 7
 @Component({
9 8
   templateUrl: './beer-modal.html'

+ 2
- 3
client/src/pages/beer/beer.ts ファイルの表示

@@ -1,5 +1,5 @@
1 1
 import { Component } from '@angular/core';
2
-import { NavController, NavParams, ModalController, ToastController } from 'ionic-angular';
2
+import { ModalController, ToastController } from 'ionic-angular';
3 3
 import { BeerService } from '../../providers/beer-service';
4 4
 import { GiphyService } from '../../providers/giphy-service';
5 5
 import { BeerModalPage } from './beer-modal';
@@ -12,8 +12,7 @@ import { BeerModalPage } from './beer-modal';
12 12
 export class BeerPage {
13 13
   private beers: Array<any>;
14 14
 
15
-  constructor(public navCtrl: NavController, public navParams: NavParams,
16
-              public beerService: BeerService, public giphyService: GiphyService,
15
+  constructor(public beerService: BeerService, public giphyService: GiphyService,
17 16
               public modalCtrl: ModalController, public toastCtrl: ToastController) {
18 17
   }
19 18
 

+ 8
- 2
client/yarn.lock ファイルの表示

@@ -2038,11 +2038,11 @@ punycode@^1.2.4, punycode@^1.4.1:
2038 2038
   version "1.4.1"
2039 2039
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
2040 2040
 
2041
-qs@6.2.0:
2041
+qs@6.2.0, qs@^6.2.0:
2042 2042
   version "6.2.0"
2043 2043
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.0.tgz#3b7848c03c2dece69a9522b0fae8c4126d745f3b"
2044 2044
 
2045
-qs@^6.2.0, qs@~6.3.0:
2045
+qs@~6.3.0:
2046 2046
   version "6.3.0"
2047 2047
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.0.tgz#f403b264f23bc01228c74131b407f18d5ea5d442"
2048 2048
 
@@ -2302,6 +2302,12 @@ rxjs@5.0.0-beta.12:
2302 2302
   dependencies:
2303 2303
     symbol-observable "^1.0.1"
2304 2304
 
2305
+rxjs@5.0.3:
2306
+  version "5.0.3"
2307
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.0.3.tgz#fc8bdf464ebf938812748e4196788f392fef9754"
2308
+  dependencies:
2309
+    symbol-observable "^1.0.1"
2310
+
2305 2311
 safe-json-parse@~1.0.1:
2306 2312
   version "1.0.1"
2307 2313
   resolved "https://registry.yarnpkg.com/safe-json-parse/-/safe-json-parse-1.0.1.tgz#3e76723e38dfdda13c9b1d29a1e07ffee4b30b57"

+ 2
- 1
server/src/main/java/com/example/beer/BeerCommandLineRunner.java ファイルの表示

@@ -15,7 +15,8 @@ class BeerCommandLineRunner implements CommandLineRunner {
15 15
     @Override
16 16
     public void run(String... strings) throws Exception {
17 17
         // top 5 beers from https://www.beeradvocate.com/lists/top/
18
-        Stream.of("Good Morning", "Kentucky Brunch Brand Stout", "ManBearPig", "King Julius", "Very Hazy", "Budweiser", "Coors Light", "PBR").forEach(name ->
18
+        Stream.of("Good Morning", "Kentucky Brunch Brand Stout", "ManBearPig", "King Julius",
19
+                "Very Hazy", "Budweiser", "Coors Light", "PBR").forEach(name ->
19 20
                 repository.save(new Beer(name))
20 21
         );
21 22
         System.out.println(repository.findAll());

バイナリ
static/beer-delete.png ファイルの表示


バイナリ
static/beer-modal.png ファイルの表示


バイナリ
static/good-beers-ui.png ファイルの表示


バイナリ
static/ionic-home.png ファイルの表示


バイナリ
static/ionic-login.png ファイルの表示


バイナリ
static/iphone-forgot-password.png ファイルの表示


バイナリ
static/iphone-login.png ファイルの表示


バイナリ
static/iphone-register.png ファイルの表示